2 Commits

Author SHA1 Message Date
f0c94b62bf WIP 2
All checks were successful
run tests / check (push) Successful in 27s
run tests / release (push) Successful in 18s
2025-12-11 11:34:05 +01:00
c33b8aae0c WIP
All checks were successful
run tests / check (push) Successful in 27s
run tests / release (push) Successful in 19s
2025-12-11 10:56:28 +01:00
29 changed files with 1521 additions and 105 deletions

1
.gitea/release.yaml Normal file
View File

@@ -0,0 +1 @@
version_descriptor: pyproject.toml

View File

@@ -0,0 +1,10 @@
name: check if project is already released
on:
- pull_request
jobs:
unittest:
runs-on: action-runner
steps:
- uses: actions/checkout@v4
- uses: ./check-is-not-released

View File

@@ -12,7 +12,99 @@ jobs:
shell: nix develop --command bash -- {0} shell: nix develop --command bash -- {0}
run: just test-python run: just test-python
test-actions: test-declare-default:
runs-on: action-runner
steps:
- uses: actions/checkout@v4
- uses: ./declare
with:
configure_runner_environment: false
- run: set -u; echo "$RELEASE_PROJECT_CURRENT_VERSION"
test-sync-versions:
runs-on: action-runner
steps:
- uses: actions/checkout@v4
# skip login locally
- if: github.repository == 'actions/release'
uses: https://gitea.puzzleyou.net/actions/configure-runner-environment@master
- run: |
echo "1.33.7" > /tmp/version-test.txt
echo "0.0.42" > /tmp/other-version.txt
- uses: ./declare
with:
configure_runner_environment: false
version_descriptor: /tmp/version-test.txt
artefact_type: oci_image
artefact_name: default-image
artefact_version_descriptor: /tmp/other-version.txt
- uses: ./sync-versions
- run: test "$(cat /tmp/version-test.txt)" = "$RELEASE_PROJECT_PLANNED_VERSION"
- run: test "$(cat /tmp/other-version.txt)" = "$RELEASE_PROJECT_PLANNED_VERSION"
test-is-not-yet-released:
runs-on: action-runner
steps:
- uses: actions/checkout@v4
# skip login locally
- if: github.repository == 'actions/release'
uses: https://gitea.puzzleyou.net/actions/configure-runner-environment@master
- uses: ./declare
with:
configure_runner_environment: false
version_descriptor: test-assets/version.txt
- uses: ./check-is-not-released
with:
configure_runner_environment: false
test-skip-release-if-already-released:
runs-on: action-runner
steps:
- uses: actions/checkout@v4
# skip login locally
- if: github.repository == 'actions/release'
uses: https://gitea.puzzleyou.net/actions/configure-runner-environment@master
- uses: ./declare
with:
configure_runner_environment: false
version_descriptor: test-assets/Cargo.toml
- uses: ./release
with:
configure_runner_environment: false
dry_run: true
sync_versions: false
test-declare-with-release-yaml:
runs-on: action-runner
steps:
- uses: actions/checkout@v4
# skip login locally
- if: github.repository == 'actions/release'
uses: https://gitea.puzzleyou.net/actions/configure-runner-environment@master
- uses: ./release
with:
dry_run: true
configure_runner_environment: false
sync_versions: false
- run: set -u; echo "$RELEASE_PROJECT_CURRENT_VERSION"
test-declare-directly:
runs-on: action-runner runs-on: action-runner
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@@ -38,6 +130,7 @@ jobs:
with: with:
type: tarball type: tarball
filename: "test-assets/foo.tar.gz" filename: "test-assets/foo.tar.gz"
package_name: foo
- uses: ./add-artefact - uses: ./add-artefact
with: with:
@@ -63,4 +156,21 @@ jobs:
repository: europe-docker.hetzner.cloud/puzzleyou/helm repository: europe-docker.hetzner.cloud/puzzleyou/helm
condition: always condition: always
- uses: ./dump - name: ensure that default artefact is set
run: env | grep "RELEASE_IMAGE_LOCAL_NAME_DEFAULT_IMAGE"
- name: ensure that additional artefact is set
run: env | grep "RELEASE_IMAGE_LOCAL_NAME_SOME_DOCKER_IMAGE"
- name: dump release environment variables
run: env | grep "RELEASE_"
- name: check version
run: echo "$RELEASE_PROJECT_CURRENT_VERSION" | grep "1.33.7"
- name: check state file is set
run: set -u; echo "$RELEASE_ACTION_STATEFILE"
- name: dump project description
uses: ./dump

View File

@@ -14,8 +14,4 @@ jobs:
runs-on: action-runner runs-on: action-runner
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: ./declare - uses: ./
with:
version_descriptor: version.txt
- uses: ./dump

1
action.yaml Symbolic link
View File

@@ -0,0 +1 @@
release/action.yaml

View File

@@ -21,6 +21,11 @@ inputs:
description: "required for tarball, sdist" description: "required for tarball, sdist"
default: "" default: ""
package_name:
required: false
description: "required for tarball"
default: ""
pattern: pattern:
required: false required: false
description: "required for wheel" description: "required for wheel"
@@ -41,13 +46,15 @@ runs:
steps: steps:
- name: add artefact - name: add artefact
run: | run: |
nix run . -- \ nix run ${{ github.action_path }} -- \
add-artefact \ add-artefact \
--state "${RELEASE_ACTION_STATEFILE}" \ --state "${RELEASE_ACTION_STATEFILE}" \
--artefact-type "${{ inputs.type }}" \ --artefact-type "${{ inputs.type }}" \
--artefact-repository "${{ inputs.repository }}" \ --artefact-repository "${{ inputs.repository }}" \
--artefact-name "${{ inputs.name }}" \ --artefact-name "${{ inputs.name }}" \
--artefact-filename "${{ inputs.filename }}" \ --artefact-filename "${{ inputs.filename }}" \
--artefact-package-name "${{ inputs.package_name }}" \
--artefact-pattern "${{ inputs.pattern }}" \ --artefact-pattern "${{ inputs.pattern }}" \
--artefact-directory "${{ inputs.directory }}" \ --artefact-directory "${{ inputs.directory }}" \
--version-descriptor "${{ inputs.version_descriptor }}" --version-descriptor "${{ inputs.version_descriptor }}" \
--write-env-vars-to-filename "$GITHUB_ENV"

View File

@@ -38,7 +38,7 @@ runs:
steps: steps:
- name: add deployment - name: add deployment
run: | run: |
nix run . -- \ nix run ${{ github.action_path }} -- \
add-deployment \ add-deployment \
--state "${RELEASE_ACTION_STATEFILE}" \ --state "${RELEASE_ACTION_STATEFILE}" \
--deployment-type "${{ inputs.type }}" \ --deployment-type "${{ inputs.type }}" \
@@ -46,4 +46,5 @@ runs:
--deployment-condition "${{ inputs.condition }}" \ --deployment-condition "${{ inputs.condition }}" \
--deployment-image-paths "${{ inputs.image_paths }}" \ --deployment-image-paths "${{ inputs.image_paths }}" \
--deployment-namespace "${{ inputs.namespace }}" \ --deployment-namespace "${{ inputs.namespace }}" \
--deployment-repository "${{ inputs.repository }}" --deployment-repository "${{ inputs.repository }}" \
--write-env-vars-to-filename "$GITHUB_ENV"

View File

@@ -0,0 +1,44 @@
name: "check not released"
description: "ensure that there is no release with the current version"
inputs:
configure_runner_environment:
required: false
default: true
runs:
using: composite
steps:
- if: inputs.configure_runner_environment == 'true'
uses: https://gitea.puzzleyou.net/actions/configure-runner-environment@master
- name: declare project if neccessary
run: |
if [[ ! -z "${RELEASE_PROJECT_CURRENT_VERSION}" ]]; then
echo "already set up."
exit 0
fi
nix run ${{ github.action_path }} -- \
declare \
--release-yaml-filename ".gitea/release.yaml" \
--gitea-instance "https://gitea.puzzleyou.net" \
--release-repository-name "${{ github.repository }}" \
--release-ref-name "${{ github.ref_name }}" \
--release-run-number "${{ github.run_number }}" \
--release-commit-sha "${{ github.sha }}" \
--write-env-vars-to-filename "$GITHUB_ENV"
- name: check release state
run: |
set +e
if [[ "$RELEASE_PROJECT_IS_RELEASED" = "1" ]]; then
VERSION="$RELEASE_PROJECT_CURRENT_VERSION"
echo "Project is already released with version ${VERSION}."
echo "You should increment the project version."
echo "If you don't, then the project will not be released."
exit -1
else
echo "Project is not yet released."
fi

View File

@@ -2,8 +2,16 @@ name: "declare project for release"
description: "start release configuration by declaring a project" description: "start release configuration by declaring a project"
inputs: inputs:
filename:
required: false
default: ".gitea/release.yaml"
description: |
location of release.yaml file. will be ignored if version_descriptor is
set.
version_descriptor: version_descriptor:
required: true required: false
default: ""
configure_runner_environment: configure_runner_environment:
required: false required: false
@@ -13,6 +21,10 @@ inputs:
required: false required: false
default: "${{ github.repository }}" default: "${{ github.repository }}"
gitea_instance:
required: false
default: "https://gitea.puzzleyou.net"
artefact_type: artefact_type:
required: false required: false
description: "known types: oci_image, tarball, wheel, sdist, npm" description: "known types: oci_image, tarball, wheel, sdist, npm"
@@ -32,6 +44,11 @@ inputs:
description: "required for tarball, sdist" description: "required for tarball, sdist"
default: "" default: ""
artefact_package_name:
required: false
description: "required for tarball"
default: ""
artefact_pattern: artefact_pattern:
required: false required: false
description: "required for wheel" description: "required for wheel"
@@ -85,48 +102,40 @@ runs:
- if: inputs.configure_runner_environment == 'true' - if: inputs.configure_runner_environment == 'true'
uses: https://gitea.puzzleyou.net/actions/configure-runner-environment@master uses: https://gitea.puzzleyou.net/actions/configure-runner-environment@master
- name: init action state
run: |
RELEASE_ACTION_STATEFILE=$(mktemp)
echo "[release] statefile: $RELEASE_ACTION_STATEFILE"
echo "RELEASE_ACTION_STATEFILE="$RELEASE_ACTION_STATEFILE"" \
>> "$GITHUB_ENV"
- name: declare release project - name: declare release project
run: | run: |
# TODO get current release version if [[ ! -z "${RELEASE_PROJECT_CURRENT_VERSION}" ]]; then
echo "already set up."
if [[ "${{ github.ref_name }}" == "master" || "${{ github.ref_name }}" == "main" ]]; then exit 0
IS_PRE_RELEASE="0"
else
IS_PRE_RELEASE="1"
fi fi
nix run . -- \ nix run ${{ github.action_path }} -- \
declare \ declare \
--state "${RELEASE_ACTION_STATEFILE}" \ --release-yaml-filename "${{ inputs.filename }}" \
--version-descriptor "${{ inputs.version_descriptor }}" \ --version-descriptor "${{ inputs.version_descriptor }}" \
--gitea-instance "${{ inputs.gitea_instance }}" \
--release-repository-name "${{ inputs.repository }}" \ --release-repository-name "${{ inputs.repository }}" \
--release-ref-name "${{ github.ref_name }}" \ --release-ref-name "${{ github.ref_name }}" \
--release-run-number "${{ github.run_number }}" \ --release-run-number "${{ github.run_number }}" \
--release-commit-sha "${{ github.sha }}" \ --release-commit-sha "${{ github.sha }}" \
--is-pre-release "${IS_PRE_RELEASE}" \ --write-env-vars-to-filename "$GITHUB_ENV"
if [[ ! -z "${{ inputs.artefact_type }}" ]]; then if [[ ! -z "${{ inputs.artefact_type }}" ]]; then
nix run . -- \ nix run ${{ github.action_path }} -- \
add-artefact \ add-artefact \
--state "${RELEASE_ACTION_STATEFILE}" \ --state "${RELEASE_ACTION_STATEFILE}" \
--artefact-type "${{ inputs.artefact_type }}" \ --artefact-type "${{ inputs.artefact_type }}" \
--artefact-repository "${{ inputs.artefact_repository }}" \ --artefact-repository "${{ inputs.artefact_repository }}" \
--artefact-name "${{ inputs.artefact_name }}" \ --artefact-name "${{ inputs.artefact_name }}" \
--artefact-filename "${{ inputs.artefact_filename }}" \ --artefact-filename "${{ inputs.artefact_filename }}" \
--artefact-package-name "${{ inputs.artefact_package_name }}" \
--artefact-pattern "${{ inputs.artefact_pattern }}" \ --artefact-pattern "${{ inputs.artefact_pattern }}" \
--artefact-directory "${{ inputs.artefact_directory }}" \ --artefact-directory "${{ inputs.artefact_directory }}" \
--version-descriptor "${{ inputs.artefact_version_descriptor }}" --version-descriptor "${{ inputs.artefact_version_descriptor }}"
fi fi
if [[ ! -z "${{ inputs.deployment_type }}" ]]; then if [[ ! -z "${{ inputs.deployment_type }}" ]]; then
nix run . -- \ nix run ${{ github.action_path }} -- \
add-deployment \ add-deployment \
--state "${RELEASE_ACTION_STATEFILE}" \ --state "${RELEASE_ACTION_STATEFILE}" \
--deployment-type "${{ inputs.deployment_type }}" \ --deployment-type "${{ inputs.deployment_type }}" \

View File

@@ -6,6 +6,6 @@ runs:
steps: steps:
- name: dump project description - name: dump project description
run: | run: |
nix run . -- \ nix run ${{ github.action_path }} -- \
dump \ dump \
--state "${RELEASE_ACTION_STATEFILE}" --state "${RELEASE_ACTION_STATEFILE}"

23
flake.lock generated
View File

@@ -34,10 +34,31 @@
"type": "github" "type": "github"
} }
}, },
"pyproject-nix": {
"inputs": {
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1764134915,
"narHash": "sha256-xaKvtPx6YAnA3HQVp5LwyYG1MaN4LLehpQI8xEdBvBY=",
"owner": "pyproject-nix",
"repo": "pyproject.nix",
"rev": "2c8df1383b32e5443c921f61224b198a2282a657",
"type": "github"
},
"original": {
"owner": "pyproject-nix",
"repo": "pyproject.nix",
"type": "github"
}
},
"root": { "root": {
"inputs": { "inputs": {
"flake-utils": "flake-utils", "flake-utils": "flake-utils",
"nixpkgs": "nixpkgs" "nixpkgs": "nixpkgs",
"pyproject-nix": "pyproject-nix"
} }
}, },
"systems": { "systems": {

View File

@@ -4,46 +4,41 @@
inputs = { inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.05"; nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.05";
flake-utils.url = "github:numtide/flake-utils"; flake-utils.url = "github:numtide/flake-utils";
pyproject-nix = {
url = "github:pyproject-nix/pyproject.nix";
inputs.nixpkgs.follows = "nixpkgs";
};
}; };
outputs = { self, nixpkgs, flake-utils, ... }: outputs = { self, nixpkgs, flake-utils, pyproject-nix }:
flake-utils.lib.eachDefaultSystem (system: flake-utils.lib.eachDefaultSystem (system:
let let
pkgs = import nixpkgs { inherit system; }; pkgs = import nixpkgs { inherit system; };
python = pkgs.python313.withPackages (ps: with ps; [ pythonProject = pyproject-nix.lib.project.loadPyproject {
isort projectRoot = ./.;
flake8
semver
toml
]);
pythonPackage = pkgs.python3Packages.buildPythonPackage {
name = "release-action";
src = ./.;
}; };
pythonInterpreter = pkgs.python313;
in in
{ {
devShells.default = pkgs.mkShell { devShells.default = pkgs.mkShell {
buildInputs = [ buildInputs = [
pkgs.envsubst
pkgs.just pkgs.just
pkgs.gitea-actions-runner pkgs.gitea-actions-runner
python (pythonInterpreter.withPackages
(pythonProject.renderers.withPackages {
python = pythonInterpreter;
extraPackages = ps: with ps; [ flake8 isort ];
}))
]; ];
}; };
packages.default = pkgs.writers.writePython3Bin packages.default = pythonInterpreter.pkgs.buildPythonPackage (
"release-action" pythonProject.renderers.buildPythonPackage {
{ python = pythonInterpreter;
libraries = with pkgs.python3Packages; [ });
semver # TODO move to setup.py?
toml
pythonPackage
];
}
(builtins.readFile ./src/main.py)
;
} }
); );
} }

View File

@@ -10,6 +10,34 @@ test-workflows:
--image "-self-hosted" \ --image "-self-hosted" \
--event pull_request \ --event pull_request \
--workflows ./.gitea/workflows/check.yaml \ --workflows ./.gitea/workflows/check.yaml \
--job test-actions --job test-declare-with-release-yaml
# --image "europe-docker.pkg.dev/puzzle-and-play/docker/action-runner-job:latest" \ act_runner exec \
--image "-self-hosted" \
--event pull_request \
--workflows ./.gitea/workflows/check.yaml \
--job test-declare-directly
act_runner exec \
--image "-self-hosted" \
--event pull_request \
--workflows ./.gitea/workflows/check.yaml \
--job test-declare-default
act_runner exec \
--image "-self-hosted" \
--event pull_request \
--workflows ./.gitea/workflows/check.yaml \
--job test-is-not-yet-released
act_runner exec \
--image "-self-hosted" \
--event pull_request \
--workflows ./.gitea/workflows/check.yaml \
--job test-skip-release-if-already-released
act_runner exec \
--image "-self-hosted" \
--event pull_request \
--workflows ./.gitea/workflows/check.yaml \
--job test-sync-versions

16
pyproject.toml Normal file
View File

@@ -0,0 +1,16 @@
[project]
name = "gitea-release-action"
version = "0.0.1"
description = "reusable action for release workflows"
authors = [ ]
requires-python = ">=3.13"
dependencies = [
"semver",
"toml",
"requests",
"pyyaml",
"packaging"
]
[project.scripts]
gitea-release-action = "main:main_cli"

88
release/action.yaml Normal file
View File

@@ -0,0 +1,88 @@
name: "release project"
description: "publish artifacte, update deployments and create gitea release"
inputs:
dry_run:
required: false
description: "do not change external state"
default: false
configure_runner_environment:
required: false
default: true
build_run:
required: false
description: "commands to run before publishing artefacts"
default: ""
sync_versions:
required: false
default: true
runs:
using: composite
steps:
- if: inputs.configure_runner_environment == 'true'
uses: https://gitea.puzzleyou.net/actions/configure-runner-environment@master
- name: declare project if neccessary
run: |
if [[ ! -z "${RELEASE_PROJECT_CURRENT_VERSION}" ]]; then
echo "already set up."
exit 0
fi
nix run ${{ github.action_path }} -- \
declare \
--release-yaml-filename ".gitea/release.yaml" \
--gitea-instance "https://gitea.puzzleyou.net" \
--release-repository-name "${{ github.repository }}" \
--release-ref-name "${{ github.ref_name }}" \
--release-run-number "${{ github.run_number }}" \
--release-commit-sha "${{ github.sha }}" \
--write-env-vars-to-filename "$GITHUB_ENV"
- name: check if already released
id: check_released
run: |
if [[ "$RELEASE_PROJECT_IS_RELEASED" == "1" ]] && [[ "$RELEASE_IS_PRERELEASE" == "0" ]]; then
echo "is_released=1" >> "$GITHUB_OUTPUT"
else
echo "is_released=0" >> "$GITHUB_OUTPUT"
fi
- name: sync versions
if: ${{ steps.check_released.outputs.is_released == '0' && inputs.sync_versions == 'true' }}
run: |
nix run ${{ github.action_path }} -- \
sync-versions \
--state "${RELEASE_ACTION_STATEFILE}"
- name: run build commands
if: ${{ steps.check_released.outputs.is_released == '0' && inputs.build_run != '' }}
run: ${{ inputs.build_run }}
- name: publish artefacts
if: ${{ steps.check_released.outputs.is_released == '0' }}
run: |
nix run ${{ github.action_path }} -- \
publish-artefacts \
--state "${RELEASE_ACTION_STATEFILE}" \
--dry-run "${{ inputs.dry_run }}"
- name: update deployments
if: ${{ steps.check_released.outputs.is_released == '0' }}
run: |
nix run ${{ github.action_path }} -- \
update-deployments \
--state "${RELEASE_ACTION_STATEFILE}" \
--dry-run "${{ inputs.dry_run }}"
- name: create release
if: ${{ steps.check_released.outputs.is_released == '0' }}
run: |
nix run ${{ github.action_path }} -- \
create-release \
--state "${RELEASE_ACTION_STATEFILE}" \
--dry-run "${{ inputs.dry_run }}"

View File

@@ -1,6 +0,0 @@
from setuptools import setup
setup(
name='release-action',
version='0.0.1.dev0',
)

View File

@@ -1,13 +1,21 @@
import pickle import pickle
import re
from argparse import ArgumentParser from argparse import ArgumentParser
from dataclasses import replace from dataclasses import replace
from os import path
from tempfile import gettempdir
from typing import Optional from typing import Optional
import yaml
from release import versioning from release import versioning
from release.context import ReleaseContext from release.context import ReleaseContext
from release.project import (ArtefactDescription, DeploymentCondition, from release.project import (ArtefactDescription, DeploymentCondition,
DeploymentDescription, HelmRelease, Npm, OciImage, DeploymentDescription, HelmRelease, Npm, OciImage,
ProjectDescription, Sdist, Tarball, Wheel) ProjectDescription, Sdist, Tarball, Wheel,
parse_project_description)
from release.release import (create_release, publish_artefacts,
update_deployments)
def load_project_description(filename) -> ProjectDescription: def load_project_description(filename) -> ProjectDescription:
@@ -25,12 +33,14 @@ def make_project_description(
return ProjectDescription(version_descriptor=version_descriptor_filename) return ProjectDescription(version_descriptor=version_descriptor_filename)
def make_context(repository_name: str, def make_context(gitea_instance: str,
repository_name: str,
ref_name: str, ref_name: str,
run_number: str, run_number: str,
commit_sha: str, commit_sha: str,
is_pre_release: bool) -> ReleaseContext: is_pre_release: bool) -> ReleaseContext:
return ReleaseContext(repository_name=repository_name, return ReleaseContext(gitea_instance=gitea_instance,
repository_name=repository_name,
ref_name=ref_name, ref_name=ref_name,
run_number=run_number, run_number=run_number,
commit_sha=commit_sha, commit_sha=commit_sha,
@@ -39,8 +49,10 @@ def make_context(repository_name: str,
def dump_project_description(project_description: ProjectDescription): def dump_project_description(project_description: ProjectDescription):
print('version_descriptor: %s' % project_description.version_descriptor) print('version_descriptor: %s' % project_description.version_descriptor)
print('project version: %s' % print('project version: %s' % project_description.project_version)
versioning.use_any(project_description.version_descriptor).version) print('planned version: %s' % project_description.planned_version)
print('is already released: %s' % project_description.is_released)
print('')
print('artefacts:') print('artefacts:')
for artefact in project_description.artefacts: for artefact in project_description.artefacts:
@@ -49,21 +61,35 @@ def dump_project_description(project_description: ProjectDescription):
artefact.version_descriptor artefact.version_descriptor
or project_description.version_descriptor) or project_description.version_descriptor)
release_info = generated.make_release_info(
project_description.context,
project_description.planned_version)
if isinstance(generated, OciImage): if isinstance(generated, OciImage):
print(' - oci image: %s' % generated.name) print(' - oci image: %s' % generated.name)
print(' repository: %s' % generated.repository) print(' repository: %s' % generated.repository)
print(' local tag: %s' % release_info.local_tag)
print(' local name: %s' % release_info.local_full_name)
print(' remote names:')
for name in release_info.remote_full_names:
print(' - %s' % name)
print(' docker tags: %s' % ", ".join(release_info.tags))
elif isinstance(generated, Tarball): elif isinstance(generated, Tarball):
print(' - tarball: %s' % generated.filename) print(' - tarball: %s' % generated.filename)
print(' package name: %s' % generated.package_name)
print(' repository: %s' % generated.repository) print(' repository: %s' % generated.repository)
print(' release version name: %s' % release_info.version_str)
elif isinstance(generated, Wheel): elif isinstance(generated, Wheel):
print(' - wheel: %s' % generated.pattern) print(' - wheel: %s' % generated.pattern)
print(' repository: %s' % generated.repository) print(' repository: %s' % generated.repository)
print(' release version name: %s' % release_info.version_str)
elif isinstance(generated, Sdist): elif isinstance(generated, Sdist):
print(' - sdist: %s' % generated.filename) print(' - sdist: %s' % generated.filename)
print(' repository: %s' % generated.repository) print(' repository: %s' % generated.repository)
print(' release version name: %s' % release_info.version_str)
elif isinstance(generated, Npm): elif isinstance(generated, Npm):
print(' - npm: %s' % generated.directory) print(' - npm: %s' % generated.directory)
@@ -99,17 +125,33 @@ def dump_project_description(project_description: ProjectDescription):
print('context:') print('context:')
context = project_description.context context = project_description.context
print(' gitea instance: %s' % context.gitea_instance)
print(' repository name: %s' % context.repository_name) print(' repository name: %s' % context.repository_name)
print(' ref name: %s' % context.ref_name) print(' ref name: %s' % context.ref_name)
print(' run number: %s' % context.run_number) print(' run number: %s' % context.run_number)
print(' commit sha: %s' % context.commit_sha) print(' commit sha: %s' % context.commit_sha)
print(' is pre-release: %s' % context.is_pre_release) print(' is pre-release: %s' % context.is_pre_release)
print('')
print('release info:')
release_info = project_description.release_info
print(' title: %s' % release_info.gitea_release_title)
print(' description: %s' % release_info.gitea_release_description)
print(' is prerelease: %s' % release_info.gitea_is_prerelease)
print(' git target commitish: %s' % release_info.gitea_git_commitish)
print(' git tags: %s' % ', '.join(release_info.git_tags))
print('')
print('environment variables:')
for key, value in project_description.environment_variables.items():
print(' %s: %s' % (key, value))
def make_artefact(type: str, def make_artefact(type: str,
repository: str, repository: str,
name: str, name: str,
filename: str, filename: str,
package_name: str,
pattern: str, pattern: str,
directory: str, directory: str,
version_descriptor) -> ArtefactDescription: version_descriptor) -> ArtefactDescription:
@@ -124,7 +166,10 @@ def make_artefact(type: str,
elif type == 'tarball': elif type == 'tarball':
assert filename is not None assert filename is not None
generated = Tarball(filename=filename, **maybe_repository) assert package_name is not None
generated = Tarball(filename=filename,
package_name=package_name,
**maybe_repository)
elif type == 'wheel': elif type == 'wheel':
assert pattern is not None assert pattern is not None
@@ -188,10 +233,34 @@ def make_deployment(type: str,
return DeploymentDescription(deployment=deployment, **maybe_condition) return DeploymentDescription(deployment=deployment, **maybe_condition)
if __name__ == '__main__': def sync_versions(project_description: ProjectDescription):
planned_version = project_description.planned_version
def sync(descriptor_filename: str):
v = versioning.use_any(descriptor_filename)
v.version = planned_version
v.store()
sync(project_description.version_descriptor)
for artefact in project_description.artefacts:
if artefact.version_descriptor is not None:
sync(artefact.version_descriptor)
def main_cli():
parser = ArgumentParser() parser = ArgumentParser()
parser.add_argument('action', choices=[ parser.add_argument('action', choices=[
'declare', 'add-artefact', 'add-deployment', 'dump']) 'declare',
'check',
'add-artefact',
'add-deployment',
'sync-versions',
'publish-artefacts',
'update-deployments',
'create-release',
'dump'
])
def nullable_string(val): def nullable_string(val):
if not val: if not val:
@@ -201,28 +270,32 @@ if __name__ == '__main__':
def space_separated(val): def space_separated(val):
return list(filter(lambda it: it != '', (val or '').split(' '))) return list(filter(lambda it: it != '', (val or '').split(' ')))
def zero_or_one(val): def true_or_false(val):
if val == '0': if val == '0' or val == 'false':
return False return False
elif val == '1': elif val == '1' or val == 'true':
return True return True
else: else:
raise Exception('flag can be "0" or "1". got: %s' % val) raise Exception('flag can be "0" or "1". got: %s' % val)
parser.add_argument('--state', required=True) parser.add_argument('--state', type=nullable_string)
parser.add_argument('--version-descriptor', type=nullable_string) parser.add_argument('--version-descriptor', type=nullable_string)
parser.add_argument('--release-yaml-filename', type=nullable_string)
parser.add_argument('--dry-run', type=true_or_false)
parser.add_argument('--gitea-instance')
parser.add_argument('--release-repository-name') parser.add_argument('--release-repository-name')
parser.add_argument('--release-ref-name') parser.add_argument('--release-ref-name')
parser.add_argument('--release-run-number') parser.add_argument('--release-run-number')
parser.add_argument('--release-commit-sha') parser.add_argument('--release-commit-sha')
parser.add_argument('--is-pre-release', type=zero_or_one) parser.add_argument('--is-pre-release', type=true_or_false)
parser.add_argument( parser.add_argument(
'--artefact-type', type=nullable_string, '--artefact-type', type=nullable_string,
choices=['oci_image', 'tarball', 'wheel', 'sdist', 'npm']) choices=['oci_image', 'tarball', 'wheel', 'sdist', 'npm'])
parser.add_argument('--artefact-repository', type=nullable_string) parser.add_argument('--artefact-repository', type=nullable_string)
parser.add_argument('--artefact-name', type=nullable_string) parser.add_argument('--artefact-name', type=nullable_string)
parser.add_argument('--artefact-package-name', type=nullable_string)
parser.add_argument('--artefact-filename', type=nullable_string) parser.add_argument('--artefact-filename', type=nullable_string)
parser.add_argument('--artefact-pattern', type=nullable_string) parser.add_argument('--artefact-pattern', type=nullable_string)
parser.add_argument('--artefact-directory', type=nullable_string) parser.add_argument('--artefact-directory', type=nullable_string)
@@ -236,28 +309,56 @@ if __name__ == '__main__':
parser.add_argument('--deployment-repository', type=nullable_string) parser.add_argument('--deployment-repository', type=nullable_string)
parser.add_argument('--deployment-condition', type=nullable_string) parser.add_argument('--deployment-condition', type=nullable_string)
parser.add_argument('--write-env-vars-to-filename')
args = parser.parse_args() args = parser.parse_args()
state_file = (args.state
or str(path.join(gettempdir(), 'release_project_state')))
def clean_repository_name(name: str) -> str:
if name.startswith('//'):
return re.match('^[/][/][^/]+[/](.+)$', name).group(1)
else:
return name
if args.action == 'declare': if args.action == 'declare':
project_description = make_project_description(args.version_descriptor) if args.version_descriptor is None:
with open(args.release_yaml_filename, 'r') as f:
project_description = parse_project_description(
yaml.safe_load(f))
else:
project_description = make_project_description(
args.version_descriptor)
if args.is_pre_release is None:
assert args.release_ref_name is not None
is_pre_release = not any(
map(lambda rn: rn in args.release_ref_name,
['master', 'main']))
else:
is_pre_release = args.is_pre_release
project_description = replace( project_description = replace(
project_description, project_description,
context=make_context(args.release_repository_name, context=make_context(
args.gitea_instance,
clean_repository_name(args.release_repository_name),
args.release_ref_name, args.release_ref_name,
args.release_run_number, args.release_run_number,
args.release_commit_sha, args.release_commit_sha,
args.is_pre_release)) is_pre_release))
save_project_description(args.state, project_description) save_project_description(state_file, project_description)
elif args.action == 'add-artefact': elif args.action == 'add-artefact':
project_description = load_project_description(args.state) project_description = load_project_description(state_file)
artefact = make_artefact(args.artefact_type, artefact = make_artefact(args.artefact_type,
args.artefact_repository, args.artefact_repository,
args.artefact_name, args.artefact_name,
args.artefact_filename, args.artefact_filename,
args.artefact_package_name,
args.artefact_pattern, args.artefact_pattern,
args.artefact_directory, args.artefact_directory,
args.version_descriptor) args.version_descriptor)
@@ -266,10 +367,10 @@ if __name__ == '__main__':
project_description, project_description,
artefacts=project_description.artefacts + [artefact]) artefacts=project_description.artefacts + [artefact])
save_project_description(args.state, project_description) save_project_description(state_file, project_description)
elif args.action == 'add-deployment': elif args.action == 'add-deployment':
project_description = load_project_description(args.state) project_description = load_project_description(state_file)
deployment = make_deployment(args.deployment_type, deployment = make_deployment(args.deployment_type,
args.deployment_release_name, args.deployment_release_name,
@@ -282,10 +383,46 @@ if __name__ == '__main__':
project_description, project_description,
deployments=project_description.deployments + [deployment]) deployments=project_description.deployments + [deployment])
save_project_description(args.state, project_description) save_project_description(state_file, project_description)
elif args.action == 'sync-versions':
project_description = load_project_description(state_file)
sync_versions(project_description)
elif args.action == 'publish-artefacts':
project_description = load_project_description(state_file)
publish_artefacts(project_description, args.dry_run)
elif args.action == 'update-deployments':
project_description = load_project_description(state_file)
update_deployments(project_description, args.dry_run)
elif args.action == 'create-release':
project_description = load_project_description(state_file)
create_release(project_description, args.dry_run)
elif args.action == 'dump': elif args.action == 'dump':
dump_project_description(load_project_description(args.state)) project_description = load_project_description(state_file)
dump_project_description(project_description)
elif args.action == 'check':
project_description = load_project_description(state_file)
else: else:
raise NotImplementedError() raise NotImplementedError()
assert project_description is not None
assert state_file is not None
env_var_filename = args.write_env_vars_to_filename
if env_var_filename is not None:
env_vars = {
'RELEASE_ACTION_STATEFILE': state_file,
**project_description.environment_variables
}
assert not any(map(lambda v: '"' in v, env_vars.values()))
with open(env_var_filename, 'a') as f:
f.write(
'\n'.join(map(lambda it: '%s=%s' % it, env_vars.items())))

12
src/release/common.py Normal file
View File

@@ -0,0 +1,12 @@
from packaging.version import parse as parse_version
from semver import Version
def python_version_str(version: Version) -> str:
return ('%d.%d.%d' % (version.major, version.minor, version.patch)) \
+ ('' if version.prerelease is None else '.%s' % version.prerelease)
def python_parse_version(txt: str) -> Version:
version = parse_version(txt)
return Version(*version.release, version.pre)

View File

@@ -3,8 +3,14 @@ from dataclasses import dataclass
@dataclass(frozen=True) @dataclass(frozen=True)
class ReleaseContext: class ReleaseContext:
gitea_instance: str
repository_name: str repository_name: str
ref_name: str ref_name: str
run_number: int run_number: int
commit_sha: str commit_sha: str
is_pre_release: bool is_pre_release: bool
def make_environment_variables(self, version):
return {
'RELEASE_IS_PRERELEASE': ('1' if self.is_pre_release else '0'),
}

View File

@@ -1,45 +1,159 @@
import operator
from dataclasses import dataclass, field from dataclasses import dataclass, field
from enum import Enum from enum import Enum
from functools import cached_property, reduce
from itertools import chain
from typing import Optional, Union from typing import Optional, Union
from semver import Version from release import toolkit, versioning
from release.common import python_version_str
from release.context import ReleaseContext from release.context import ReleaseContext
from release.versioning import Version
DEFAULT_OCI_IMAGE_REPOSITORY = 'europe-docker.pkg.dev/puzzle-and-play/helm' DEFAULT_OCI_IMAGE_REPOSITORY = 'europe-docker.pkg.dev/puzzle-and-play/docker'
DEFAULT_HELM_CHART_REPOSITORY = 'europe-docker.pkg.dev/puzzle-and-play/helm'
DEFAULT_GITEA_PACKAGE_INSTANCE = 'https://gitea.puzzleyou.net' DEFAULT_GITEA_PACKAGE_INSTANCE = 'https://gitea.puzzleyou.net'
DEFAULT_PYPI_REPOSITORY_NAME = 'gitea' DEFAULT_PYPI_REPOSITORY_NAME = 'gitea'
HELM_DEFAULT_TIMEOUT = '5m'
def _normalize_env_var_fragment(txt: str) -> str:
return txt.upper().replace('-', '_').replace('.', '_')
@dataclass(frozen=True)
class OciImageReleaseInfo:
image_name: str
local_tag: str
local_full_name: str
remote_full_names: str
tags: list[str]
@dataclass(frozen=True) @dataclass(frozen=True)
class OciImage: class OciImage:
# NOTE: tag is exported as IMAGE_TAG_$NAME
name: str name: str
repository: str = DEFAULT_OCI_IMAGE_REPOSITORY repository: str = DEFAULT_OCI_IMAGE_REPOSITORY
def make_environment_variables(self, context, version):
tag = context.commit_sha
normalized_image_name = _normalize_env_var_fragment(self.name)
release_info = self.make_release_info(context, version)
return {
'RELEASE_IMAGE_TAG': tag,
'RELEASE_IMAGE_LOCAL_NAME_%s' % normalized_image_name:
release_info.local_full_name,
}
def make_release_info(self, context: ReleaseContext, version: Version):
version_tag = 'v%s' % version
tags = ([version_tag, '%s.latest' % context.ref_name] + (
['development']
if context.is_pre_release
else [
'v%d.%d' % (version.major, version.minor),
'v%d' % version.major,
'latest',
]))
remote_full_names = list(
map(lambda tag: '%s/%s:%s' % (self.repository, self.name, tag),
tags))
return OciImageReleaseInfo(
image_name=self.name,
local_tag=context.commit_sha,
local_full_name='%s:%s' % (self.name, context.commit_sha),
remote_full_names=remote_full_names,
tags=tags)
@dataclass(frozen=True)
class TarballReleaseInfo:
filename: str
package_name: str
version_str: str
repository: str
@dataclass(frozen=True) @dataclass(frozen=True)
class Tarball: class Tarball:
filename: str filename: str
package_name: str
repository: str = DEFAULT_GITEA_PACKAGE_INSTANCE repository: str = DEFAULT_GITEA_PACKAGE_INSTANCE
def make_environment_variables(self, context, version):
return {}
def make_release_info(self, context: ReleaseContext, version: Version):
return TarballReleaseInfo(
filename=self.filename,
package_name=self.package_name,
repository=self.repository,
version_str=str(version))
@dataclass(frozen=True)
class WheelReleaseInfo:
pattern: str
repository: str
version_str: str
@dataclass(frozen=True) @dataclass(frozen=True)
class Wheel: class Wheel:
pattern: str pattern: str
repository: str = DEFAULT_PYPI_REPOSITORY_NAME repository: str = DEFAULT_PYPI_REPOSITORY_NAME
def make_environment_variables(self, context, version):
return {}
def make_release_info(self, context: ReleaseContext, version: Version):
return WheelReleaseInfo(
pattern=self.pattern,
repository=self.repository,
version_str=python_version_str(version))
@dataclass(frozen=True)
class SdistReleaseInfo:
filename: str
repository: str
version_str: str
@dataclass(frozen=True) @dataclass(frozen=True)
class Sdist: class Sdist:
filename: str filename: str
repository: str = DEFAULT_PYPI_REPOSITORY_NAME repository: str = DEFAULT_PYPI_REPOSITORY_NAME
def make_environment_variables(self, context, version):
return {}
def make_release_info(self, context: ReleaseContext, version: Version):
return SdistReleaseInfo(
filename=self.filename,
repository=self.repository,
version_str=python_version_str(version))
@dataclass(frozen=True)
class NpmReleaseInfo:
directory: str
@dataclass(frozen=True) @dataclass(frozen=True)
class Npm: class Npm:
directory: str directory: str
def make_environment_variables(self, context, version):
return {}
def make_release_info(self, context: ReleaseContext, version: Version):
return NpmReleaseInfo(directory=self.directory)
@dataclass(frozen=True) @dataclass(frozen=True)
class ArtefactDescription: class ArtefactDescription:
@@ -48,11 +162,34 @@ class ArtefactDescription:
@dataclass(frozen=True) @dataclass(frozen=True)
class HelmRelease: class HelmReleaseInfo:
release_name: str release_name: str
namespace: str namespace: str
repository: str
image_tag: str
image_paths: str
timeout: str
@dataclass(frozen=True)
class HelmRelease:
release_name: str
namespace: str = None
image_paths: list[str] = field(default_factory=lambda: ['image.tag']) image_paths: list[str] = field(default_factory=lambda: ['image.tag'])
repository: str = DEFAULT_OCI_IMAGE_REPOSITORY repository: str = DEFAULT_HELM_CHART_REPOSITORY
def make_environment_variables(self, context, version):
return {}
def make_release_info(self, context: ReleaseContext, version: Version):
return HelmReleaseInfo(
release_name=self.release_name,
namespace=self.namespace or self.release_name,
repository=self.repository,
image_tag='v%s' % version,
image_paths=self.image_paths,
timeout=HELM_DEFAULT_TIMEOUT,
)
class DeploymentCondition(Enum): class DeploymentCondition(Enum):
@@ -68,6 +205,15 @@ class DeploymentDescription:
condition: DeploymentCondition = DeploymentCondition.PRE_RELEASE_ONLY condition: DeploymentCondition = DeploymentCondition.PRE_RELEASE_ONLY
@dataclass(frozen=True)
class ProjectReleaseInfo:
gitea_release_title: str
gitea_release_description: str
gitea_is_prerelease: bool
gitea_git_commitish: str
git_tags: list[str]
@dataclass(frozen=True) @dataclass(frozen=True)
class ProjectDescription: class ProjectDescription:
version_descriptor: str # filename version_descriptor: str # filename
@@ -75,4 +221,184 @@ class ProjectDescription:
deployments: list[DeploymentDescription] = field( deployments: list[DeploymentDescription] = field(
default_factory=lambda: []) default_factory=lambda: [])
context: Optional[ReleaseContext] = None context: Optional[ReleaseContext] = None
planned_version: Optional[Version] = None
@cached_property
def release_info(self):
if self.context is None:
return None
version = self.planned_version
return ProjectReleaseInfo(
gitea_release_title='Version %s' % version,
gitea_release_description='',
gitea_is_prerelease=self.context.is_pre_release,
gitea_git_commitish=self.context.commit_sha,
git_tags=['v%s' % version] + (
['development']
if self.context.is_pre_release
else [
'v%d.%d' % (version.major, version.minor),
'v%d' % version.major,
'latest',
]))
@cached_property
def planned_version(self):
if self.context is not None and self.context.is_pre_release:
prerelease_version = self.context.run_number or 0
return self.project_version.replace(
prerelease='dev%s' % prerelease_version)
else:
return self.project_version
@cached_property
def project_version(self):
return versioning.use_any(self.version_descriptor).version
@cached_property
def is_released(self):
if self.gitea_tool is None:
return None
return self.gitea_tool.is_released(self.project_version)
@cached_property
def gitea_tool(self):
if self.context is None:
return None
if self.context.repository_name is None:
return None
if self.context.gitea_instance is None:
return None
return toolkit.Gitea(self.context.gitea_instance,
self.context.repository_name,
self.context.is_pre_release or False)
@property
def environment_variables(self):
version = self.planned_version
return reduce(
operator.or_,
chain(
map(lambda a:
a.generated.make_environment_variables(
self.context, version),
self.artefacts),
map(lambda d:
d.deployment.make_environment_variables(
self.context, version),
self.deployments),
([self.context.make_environment_variables(version)]
if self.context is not None
else []),
[{
'RELEASE_PROJECT_CURRENT_VERSION':
str(self.project_version),
'RELEASE_PROJECT_PLANNED_VERSION':
str(self.planned_version),
'RELEASE_PROJECT_IS_RELEASED':
'1' if self.is_released else '0',
}]
))
def parse_project_description(obj):
def optional(obj, field, acc=lambda o, f: o[f]):
if field in obj:
return {field: acc(obj, field)}
else:
return {}
def parse_oci_image(img):
assert 'name' in img
return OciImage(name=img['name'],
**optional(img, 'repository'))
def parse_tarball(tar):
assert 'filename' in tar
return Tarball(filename=tar['filename'],
package_name=tar['package_name'],
**optional(tar, 'repository'))
def parse_wheel(whl):
assert 'pattern' in whl
return Wheel(pattern=whl['pattern'],
**optional(whl, 'repository'))
def parse_sdist(sdist):
assert 'filename' in sdist
return Sdist(filename=sdist['filename'],
**optional(sdist, 'repository'))
def parse_npm(npm):
assert 'directory' in npm
return Npm(directory=npm['directory'])
def parse_artefact(art):
assert 'type' in art
if art['type'] == 'oci_image' or art['type'] == 'docker':
generated = parse_oci_image(art)
elif art['type'] == 'tarball':
generated = parse_tarball(art)
elif art['type'] == 'wheel':
generated = parse_wheel(art)
elif art['type'] == 'sdist':
generated = parse_sdist(art)
elif art['type'] == 'npm':
generated = parse_npm(art)
else:
raise Exception('unknown artefact type: %s' % art['type'])
return ArtefactDescription(
generated=generated,
**optional(art, 'version_descriptor',))
def parse_helm(helm):
assert 'release_name' in helm
return HelmRelease(
release_name=helm['release_name'],
**optional(helm, 'namespace'),
**optional(helm, 'image_paths'),
**optional(helm, 'repository'))
def parse_condition(cnd, _):
assert 'condition' in cnd
txt = cnd['condition'].lower()
if txt == 'always':
return DeploymentCondition.ALWAYS
if txt == 'never':
return DeploymentCondition.NEVER
if txt == 'pre_release_only':
return DeploymentCondition.PRE_RELEASE_ONLY
if txt == 'release_only':
return DeploymentCondition.RELEASE_ONLY
else:
raise Exception('unknown condition: %s' % txt)
def parse_deployment(dpl):
assert 'type' in dpl
if dpl['type'] == 'helm':
deployment = parse_helm(dpl)
else:
raise Exception('unknown deployment type: %s' % dpl['type'])
return DeploymentDescription(
deployment=deployment,
**optional(dpl, 'condition', parse_condition))
assert 'version_descriptor' in obj
return ProjectDescription(
version_descriptor=obj['version_descriptor'],
artefacts=list(map(parse_artefact, obj.get('artefacts', []))),
deployments=list(map(parse_deployment, obj.get('deployments', []))),
)

236
src/release/release.py Normal file
View File

@@ -0,0 +1,236 @@
import json
import subprocess
from os import path
from tempfile import NamedTemporaryFile, TemporaryDirectory
from release.project import (DeploymentCondition, HelmRelease, HelmReleaseInfo,
Npm, NpmReleaseInfo, OciImage,
OciImageReleaseInfo, ProjectDescription, Sdist,
SdistReleaseInfo, Tarball, TarballReleaseInfo,
Wheel, WheelReleaseInfo)
DEFAULT_PACKAGE_OWNER = 'puzzleYOU'
class Cli:
def __init__(self, dry_run: bool):
self.dry_run = dry_run
def __call__(self, *cmd, cwd=None):
try:
cleaned_cmd = list(filter(lambda it: it != '', cmd))
if cwd is not None:
print('$> cd %s' % cwd)
print('$> %s' % ' '.join(cleaned_cmd))
if not self.dry_run:
res = subprocess.run(cleaned_cmd,
check=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
cwd=cwd)
output = res.stdout
else:
output = b''
for line in output.decode('utf-8').splitlines():
print(' | %s' % line)
return output
except subprocess.CalledProcessError as exc:
for line in exc.stdout.decode('utf-8').splitlines():
print('[ERR] %s' % line)
raise Exception('%s failed: %s' % (cleaned_cmd[0],
exc.stderr.decode('utf-8')))
def publish_oci_image(info: OciImageReleaseInfo, cli: Cli):
print('-- publishing oci image --')
print('name: %s' % info.image_name)
for remote_full_name in info.remote_full_names:
cli('docker', 'tag', info.local_full_name, remote_full_name)
cli('docker', 'push', remote_full_name)
print()
def publish_tarball(info: TarballReleaseInfo, cli: Cli):
print('-- publishing tarball --')
print('filename: %s' % info.filename)
cli('curl',
'--netrc',
'--fail-with-body',
'--upload-file', info.filename,
'-i',
'-X', 'PUT',
'%s/api/packages/%s/generic/%s/%s/%s' % (
info.repository,
DEFAULT_PACKAGE_OWNER,
info.package_name,
info.version_str,
path.basename(info.filename)))
print()
def publish_wheel(info: WheelReleaseInfo, cli: Cli):
print('-- publishing wheel --')
cli('twine',
'upload',
'--verbose',
'--repository', info.repository,
info.pattern)
print()
def publish_sdist(info: SdistReleaseInfo, cli: Cli):
print('-- publishing python source distribution --')
cli('twine',
'upload',
'--verbose',
'--repository', info.repository,
info.filename)
print()
def publish_npm(info: NpmReleaseInfo, cli: Cli):
print('-- publishing npm package --')
cli('npm', 'publish', cwd=info.directory)
print()
def publish_artefacts(project: ProjectDescription, dry_run: bool = False):
cli = Cli(dry_run)
context = project.context
planned_version = project.planned_version
for artefact in project.artefacts:
release_info = artefact.generated.make_release_info(
context, planned_version)
if isinstance(artefact.generated, OciImage):
publish_oci_image(release_info, cli)
elif isinstance(artefact.generated, Tarball):
publish_tarball(release_info, cli)
elif isinstance(artefact.generated, Wheel):
publish_wheel(release_info, cli)
elif isinstance(artefact.generated, Sdist):
publish_sdist(release_info, cli)
elif isinstance(artefact.generated, Npm):
publish_npm(release_info, cli)
else:
print('unknown artefact: %s' % artefact.generated)
raise Exception('cannot publish unknown artefact')
def update_helm_release(info: HelmReleaseInfo, cli: Cli):
ro_cli = Cli(dry_run=False)
print('-- updating helm release --')
metadata = json.loads(ro_cli('helm',
'get', 'metadata', info.release_name,
'-n', info.namespace, '-o', 'json').decode())
chart_name = metadata['chart']
chart_version = metadata['version']
with TemporaryDirectory() as td:
ro_cli('helm',
'pull', 'oci://%s/%s' % (info.repository, chart_name),
'--version', chart_version,
'-d', td)
chart_filename = '%s/%s-%s.tgz' % (td, chart_name, chart_version)
value_overrides = ','.join(
map(lambda t: '%s=%s' % (t, info.image_tag), info.image_paths))
cli('helm',
'upgrade', info.release_name,
chart_filename,
'--version', chart_version,
'--namespace', info.namespace,
'--reuse-values',
'--set', value_overrides,
'--timeout', info.timeout)
def update_deployments(project: ProjectDescription, dry_run: bool = False):
cli = Cli(dry_run)
context = project.context
planned_version = project.planned_version
for deployment in project.deployments:
release_info = deployment.deployment.make_release_info(
context, planned_version)
if deployment.condition == DeploymentCondition.NEVER:
continue
if (deployment.condition == DeploymentCondition.RELEASE_ONLY
and context.is_pre_release):
continue
if (deployment.condition == DeploymentCondition.PRE_RELEASE_ONLY
and not context.is_pre_release):
continue
if isinstance(deployment.deployment, HelmRelease):
update_helm_release(release_info, cli)
else:
print('unknown deployment: %s' % deployment.deployment)
raise Exception('cannot publish unknown deployment')
def create_release(project: ProjectDescription, dry_run: bool = False):
cli = Cli(dry_run)
info = project.release_info
context = project.context
assert len(info.git_tags) > 0
# https://gitea.com/api/swagger#/repository/repoCreateRelease
payload = {
'body': info.gitea_release_description,
'draft': False,
'name': info.gitea_release_title,
'prerelease': info.gitea_is_prerelease,
'tag_message': info.gitea_release_title,
'tag_name': info.git_tags[0],
'target_commitish': info.gitea_git_commitish,
}
print('-- creating gitea release --')
print('POST body:')
print(json.dumps(payload, indent=2))
with NamedTemporaryFile(mode='w') as tf:
json.dump(payload, tf)
tf.flush()
cli('curl',
'--netrc',
'--fail-with-body',
'-i',
'-H', 'Content-Type: application/json',
'--data', '@%s' % tf.name,
'-X', 'POST',
'%s/api/v1/repos/%s/releases' % (context.gitea_instance,
context.repository_name))
print()
print('-- adding git tags --')
for tag in info.git_tags[1:]:
cli('git', 'tag', '-f', tag, info.gitea_git_commitish)
cli('git', 'push', '-f', 'origin', '--tags')

View File

@@ -0,0 +1,50 @@
version_descriptor: 'test-assets/version.txt'
artefacts:
- type: oci_image
name: productdesignerd
- type: docker
name: motacilla
version_descriptor: 'test-assets/Cargo.toml'
repository: remote
- type: tarball
package_name: 'yaac'
filename: '/tmp/yaac.tar.gz'
- type: tarball
package_name: 'yaac'
filename: '/tmp/yaac.tar.gz'
repository: balls
- type: wheel
pattern: './scratch/wheels/*.whl'
- type: wheel
pattern: './scratch/wheels/*.whl'
repository: other
- type: sdist
filename: './dist/papyru-0.0.1.tar.gz'
- type: sdist
filename: './dist/papyru-0.0.1.tar.gz'
repository: 'pypi'
- type: npm
directory: ./dist/browser
deployments:
- type: helm
release_name: 'productdesignerd-testing'
- type: helm
condition: release_only
release_name: foo
namespace: bar
image_paths:
- foo
- bar
- baz
repository: helms

View File

@@ -9,4 +9,5 @@ class TestReleaseContext(TestCase):
ref_name='testing', ref_name='testing',
run_number=42, run_number=42,
is_pre_release=True, is_pre_release=True,
commit_sha='0AB123') commit_sha='0AB123',
gitea_instance='http://gitea.foo.intern')

View File

@@ -1,8 +1,26 @@
from os import path
from unittest import TestCase from unittest import TestCase
from unittest.mock import patch
import yaml
from semver import Version
from release.context import ReleaseContext
from release.project import (ArtefactDescription, DeploymentCondition, from release.project import (ArtefactDescription, DeploymentCondition,
DeploymentDescription, HelmRelease, Npm, OciImage, DeploymentDescription, HelmRelease,
ProjectDescription, Sdist, Tarball, Wheel) HelmReleaseInfo, Npm, NpmReleaseInfo, OciImage,
OciImageReleaseInfo, ProjectDescription,
ProjectReleaseInfo, Sdist, SdistReleaseInfo,
Tarball, TarballReleaseInfo, Wheel,
WheelReleaseInfo, parse_project_description)
class MockGiteaTool:
def __init__(self, *args, **kwargs):
pass
def is_released(self, version):
return False
class TestProjectDescription(TestCase): class TestProjectDescription(TestCase):
@@ -51,7 +69,9 @@ class TestProjectDescription(TestCase):
ProjectDescription( ProjectDescription(
version_descriptor='version.txt', version_descriptor='version.txt',
artefacts=[ artefacts=[
ArtefactDescription(generated=Tarball('/tmp/yaac.tar.gz')) ArtefactDescription(generated=Tarball(
filename='/tmp/yaac.tar.gz',
package_name='yaac'))
]) ])
# papyru # papyru
@@ -124,3 +144,258 @@ class TestProjectDescription(TestCase):
release_name='prngl-testing', release_name='prngl-testing',
namespace='prngl-testing')) namespace='prngl-testing'))
]) ])
@patch('release.project.toolkit.Gitea', MockGiteaTool)
def test_environment_variables(self):
# motacilla
desc = ProjectDescription(
version_descriptor=path.join(
path.dirname(__file__), 'assets/setup.py'),
artefacts=[
ArtefactDescription(generated=OciImage(
repository='remote', name='motacilla')),
ArtefactDescription(generated=OciImage(
repository='remote', name='motacilla-cdn')),
],
deployments=[
DeploymentDescription(
condition=DeploymentCondition.PRE_RELEASE_ONLY,
deployment=HelmRelease(
release_name='motacilla-de-testing',
namespace='motacilla-de-testing',
image_paths=['image.cms.tag', 'image.cdn.tag'])),
DeploymentDescription(
condition=DeploymentCondition.PRE_RELEASE_ONLY,
deployment=HelmRelease(
release_name='motacilla-schmidt-testing',
namespace='motacilla-schmidt-testing',
image_paths=['image.cms.tag', 'image.cdn.tag'])),
DeploymentDescription(
condition=DeploymentCondition.PRE_RELEASE_ONLY,
deployment=HelmRelease(
release_name='motacilla-be-testing',
namespace='motacilla-be-testing',
image_paths=['image.cms.tag', 'image.cdn.tag'])),
],
context=ReleaseContext(repository_name='resi',
ref_name='testing',
run_number=42,
is_pre_release=True,
commit_sha='0AB123',
gitea_instance='http://gitea.foo.intern')
)
self.maxDiff = None
self.assertEqual(
desc.environment_variables,
{
'RELEASE_IMAGE_TAG': '0AB123',
'RELEASE_IMAGE_LOCAL_NAME_MOTACILLA': 'motacilla:0AB123',
'RELEASE_IMAGE_LOCAL_NAME_MOTACILLA_CDN':
'motacilla-cdn:0AB123',
'RELEASE_IS_PRERELEASE': '1',
'RELEASE_PROJECT_IS_RELEASED': '0',
'RELEASE_PROJECT_CURRENT_VERSION': '2.10.4',
'RELEASE_PROJECT_PLANNED_VERSION': '2.10.4-dev42'
}
)
def test_tarball_release_info(self):
tarball = Tarball(filename='foo.tar.gz', package_name='foo')
self.assertEqual(
TarballReleaseInfo(
filename='foo.tar.gz',
package_name='foo',
version_str='1.2.3-dev4',
repository='https://gitea.puzzleyou.net'),
tarball.make_release_info(None, Version(1, 2, 3, 'dev4')))
def test_wheel_release_info(self):
wheel = Wheel(pattern='dist/wheels/*')
self.assertEqual(
WheelReleaseInfo(pattern='dist/wheels/*',
repository='gitea',
version_str='1.2.3.dev4'),
wheel.make_release_info(None, Version(1, 2, 3, 'dev4')))
def test_sdist_release_info(self):
sdist = Sdist(filename='somethingsomething.tar.gz')
self.assertEqual(
SdistReleaseInfo(version_str='1.2.3.dev4',
filename='somethingsomething.tar.gz',
repository='gitea'),
sdist.make_release_info(None, Version(1, 2, 3, 'dev4')))
def test_npm_release_info(self):
npm = Npm(directory='./')
self.assertEqual(
NpmReleaseInfo(directory='./'),
npm.make_release_info(None, Version(1, 2, 3, 'dev4')))
def test_helm_release_info(self):
helm = HelmRelease(release_name='foo',
namespace='bar',
image_paths=['foo.bar', 'baz.blubb'],
repository='ludicrous')
self.assertEqual(
HelmReleaseInfo(release_name='foo',
namespace='bar',
repository='ludicrous',
image_tag='v1.2.3-dev4',
image_paths=['foo.bar', 'baz.blubb'],
timeout='5m'),
helm.make_release_info(None, Version(1, 2, 3, 'dev4')))
def test_oci_image_release_info(self):
self.maxDiff = None
img = OciImage(name='test', repository='remote')
pre_release_version = Version(1, 33, 7, 'dev42')
pre_release_context = ReleaseContext(
repository_name='resi',
ref_name='testing',
run_number=42,
is_pre_release=True,
commit_sha='PROBABLY_BROKEN',
gitea_instance='http://gitea.foo.intern')
self.assertEqual(
OciImageReleaseInfo(
image_name='test',
local_tag='PROBABLY_BROKEN',
local_full_name='test:PROBABLY_BROKEN',
remote_full_names=[
'remote/test:v1.33.7-dev42',
'remote/test:testing.latest',
'remote/test:development',
],
tags=['v1.33.7-dev42', 'testing.latest', 'development']),
img.make_release_info(pre_release_context,
pre_release_version))
release_version = Version(1, 33, 7)
release_context = ReleaseContext(
repository_name='resi',
ref_name='master',
run_number=43,
is_pre_release=False,
commit_sha='finalv2',
gitea_instance='http://gitea.foo.intern')
self.assertEqual(
OciImageReleaseInfo(
image_name='test',
local_tag='finalv2',
local_full_name='test:finalv2',
remote_full_names=[
'remote/test:v1.33.7',
'remote/test:master.latest',
'remote/test:v1.33',
'remote/test:v1',
'remote/test:latest',
],
tags=['v1.33.7',
'master.latest',
'v1.33',
'v1',
'latest']),
img.make_release_info(release_context, release_version))
def test_project_release_info(self):
project_0 = ProjectDescription(
version_descriptor='test-assets/version.txt')
self.assertIsNone(project_0.release_info)
project_pre = ProjectDescription(
version_descriptor='test-assets/version.txt',
context=ReleaseContext(
repository_name='resi',
ref_name='testing',
run_number=42,
is_pre_release=True,
commit_sha='PROBABLY_BROKEN',
gitea_instance='http://gitea.foo.intern'))
self.assertEqual(
ProjectReleaseInfo(
gitea_release_title='Version 1.33.7-dev42',
gitea_release_description='',
gitea_is_prerelease=True,
gitea_git_commitish='PROBABLY_BROKEN',
git_tags=['v1.33.7-dev42', 'development'],
),
project_pre.release_info)
project = ProjectDescription(
version_descriptor='test-assets/version.txt',
context=ReleaseContext(
repository_name='resi',
ref_name='master',
run_number=42,
is_pre_release=False,
commit_sha='PROBABLY_BROKEN',
gitea_instance='http://gitea.foo.intern'))
self.assertEqual(
ProjectReleaseInfo(
gitea_release_title='Version 1.33.7',
gitea_release_description='',
gitea_is_prerelease=False,
gitea_git_commitish='PROBABLY_BROKEN',
git_tags=['v1.33.7', 'v1.33', 'v1', 'latest'],
),
project.release_info)
def test_load_from_file(self):
expected = ProjectDescription(
version_descriptor='test-assets/version.txt',
artefacts=[
ArtefactDescription(generated=OciImage(
name='productdesignerd')),
ArtefactDescription(
generated=OciImage(repository='remote', name='motacilla'),
version_descriptor='test-assets/Cargo.toml'),
ArtefactDescription(generated=Tarball(
filename='/tmp/yaac.tar.gz', package_name='yaac')),
ArtefactDescription(generated=Tarball(
filename='/tmp/yaac.tar.gz',
package_name='yaac',
repository='balls')),
ArtefactDescription(generated=Wheel(
pattern='./scratch/wheels/*.whl')),
ArtefactDescription(generated=Wheel(
pattern='./scratch/wheels/*.whl', repository='other')),
ArtefactDescription(generated=Sdist(
filename='./dist/papyru-0.0.1.tar.gz')),
ArtefactDescription(generated=Sdist(
filename='./dist/papyru-0.0.1.tar.gz',
repository='pypi')),
ArtefactDescription(generated=Npm(directory='./dist/browser')),
],
deployments=[
DeploymentDescription(
condition=DeploymentCondition.PRE_RELEASE_ONLY,
deployment=HelmRelease(
release_name='productdesignerd-testing')),
DeploymentDescription(
condition=DeploymentCondition.RELEASE_ONLY,
deployment=HelmRelease(
release_name='foo',
namespace='bar',
image_paths=['foo', 'bar', 'baz'],
repository='helms',
)),
])
with open(path.join(
path.dirname(__file__), 'assets/release.yaml'), 'r') as f:
project = parse_project_description(yaml.safe_load(f))
self.maxDiff = None
self.assertEqual(project, expected)

View File

@@ -18,12 +18,12 @@ def _test_can_read_version(test, filename, expected):
def _test_can_write_version(test, filename): def _test_can_write_version(test, filename):
v = use_any(_asset_path(filename)) v = use_any(_asset_path(filename))
v.version = Version(1, 33, 7) v.version = Version(1, 33, 7, 42)
with NamedTemporaryFile() as tf: with NamedTemporaryFile() as tf:
v.store(tf.name) v.store(tf.name)
v2 = use_any(tf.name) v2 = use_any(tf.name)
test.assertEqual(v2.version, Version(1, 33, 7)) test.assertEqual(v2.version, Version(1, 33, 7, 42))
class TestSetupPy(TestCase): class TestSetupPy(TestCase):

36
src/release/toolkit.py Normal file
View File

@@ -0,0 +1,36 @@
import requests
class Gitea:
def __init__(self,
gitea_instance: str,
repository_name: str,
is_pre_release: bool):
self.instance = gitea_instance
self.repository_name = repository_name
self.is_pre_release = is_pre_release
def is_released(self, version):
resp = requests.get(
'%s/repos/%s/releases/tags/%s' % (self.api_url,
self.repository_name,
version_to_git_tag(version)))
if resp.status_code == 404:
return False
resp.raise_for_status()
return True
def api_get(self, endpoint):
resp = requests.get('%s/%s' % (self.api_url, endpoint))
resp.raise_for_status()
return resp.json()
@property
def api_url(self):
return '%s/api/v1' % self.instance
def version_to_git_tag(version):
return 'v%s' % version

View File

@@ -6,6 +6,8 @@ from logging import getLogger
import toml import toml
from semver import Version from semver import Version
from release.common import python_parse_version, python_version_str
logger = getLogger(__name__) logger = getLogger(__name__)
RE_SETUP_PY = re.compile(r'version\s?=\s?[\'"](.*)[\'"]') RE_SETUP_PY = re.compile(r'version\s?=\s?[\'"](.*)[\'"]')
@@ -24,12 +26,13 @@ class SetupPy:
self.filename = filename self.filename = filename
self.content = content self.content = content
self.version = Version.parse(version_string.group(1)) self.version = python_parse_version(version_string.group(1))
def store(self, destination=None): def store(self, destination=None):
destination = destination or self.filename destination = destination or self.filename
edited = RE_SETUP_PY.sub( edited = RE_SETUP_PY.sub(
'version=\'%s\'' % self.version, self.content) 'version=\'%s\'' % python_version_str(self.version),
self.content)
with open(destination, 'w') as f: with open(destination, 'w') as f:
f.write(edited) f.write(edited)
@@ -44,6 +47,7 @@ class Structured:
for part in item_path: for part in item_path:
cur = cur[part] cur = cur[part]
self.filename = filename
self.format = format self.format = format
self.item_path = item_path self.item_path = item_path
self.content = obj self.content = obj

13
sync-versions/action.yaml Normal file
View File

@@ -0,0 +1,13 @@
name: "sync version descriptors"
description: "update all version descriptors to the planned version."
inputs: {}
runs:
using: composite
steps:
- name: sync versions
run: |
nix run ${{ github.action_path }} -- \
sync-versions \
--state "${RELEASE_ACTION_STATEFILE}"

View File

@@ -1 +0,0 @@
0.0.1