diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..c4b17d7 --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use_flake diff --git a/.gitea/CODEOWNERS b/.gitea/CODEOWNERS new file mode 100644 index 0000000..0a86997 --- /dev/null +++ b/.gitea/CODEOWNERS @@ -0,0 +1 @@ +* @puzzleYOU/team-gelb diff --git a/.gitea/workflows/check.yaml b/.gitea/workflows/check.yaml new file mode 100644 index 0000000..678b73e --- /dev/null +++ b/.gitea/workflows/check.yaml @@ -0,0 +1,66 @@ +name: run tests +on: + - pull_request + - workflow_call + +jobs: + unittest: + runs-on: action-runner + steps: + - uses: actions/checkout@v4 + - name: unittest + shell: nix develop --command bash -- {0} + run: just test-python + + test-actions: + runs-on: action-runner + steps: + - uses: actions/checkout@v4 + + - uses: ./declare + with: + configure_runner_environment: false + version_descriptor: test-assets/version.txt + repository: actions/release + artefact_type: oci_image + artefact_name: default-image + deployment_type: helm_release + deployment_release_name: example-testing + + - uses: ./add-artefact + with: + type: "oci_image" + name: "some-docker-image" + repository: "special" + version_descriptor: "test-assets/Cargo.toml" + + - uses: ./add-artefact + with: + type: tarball + filename: "test-assets/foo.tar.gz" + + - uses: ./add-artefact + with: + type: wheel + pattern: "test-assets/wheels/*.whl" + + - uses: ./add-artefact + with: + type: sdist + filename: "test-assets/pypkgs.42.tar.gz" + + - uses: ./add-artefact + with: + type: npm + directory: "test-assets/browser/dist" + + - uses: ./add-deployment + with: + type: helm_release + release_name: other-testing + image_paths: image.foo.tag image.bar.tag + namespace: different-testing + repository: europe-docker.hetzner.cloud/puzzleyou/helm + condition: always + + - uses: ./dump diff --git a/.gitea/workflows/release.yaml b/.gitea/workflows/release.yaml new file mode 100644 index 0000000..913c66c --- /dev/null +++ b/.gitea/workflows/release.yaml @@ -0,0 +1,21 @@ +name: run tests +on: + push: + branches: + - master + - testing + +jobs: + check: + uses: ./.gitea/workflows/check.yaml + + release: + needs: ["check"] + runs-on: action-runner + steps: + - uses: actions/checkout@v4 + - uses: ./declare + with: + version_descriptor: version.txt + + - uses: ./dump diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6fc9b47 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/.direnv/ +__pycache__ diff --git a/add-artefact/action.yaml b/add-artefact/action.yaml new file mode 100644 index 0000000..7c361c3 --- /dev/null +++ b/add-artefact/action.yaml @@ -0,0 +1,53 @@ +name: "add artefact to release project" +description: "add additional artefact to initialized project" + +inputs: + type: + required: true + description: "known types: oci_image, tarball, wheel, sdist, npm" + + repository: + required: false + description: "allowed for oci_image, tarbal, wheel, sdist" + default: "" + + name: + required: false + description: "required for oci_image" + default: "" + + filename: + required: false + description: "required for tarball, sdist" + default: "" + + pattern: + required: false + description: "required for wheel" + default: "" + + directory: + required: false + description: "required for npm" + default: "" + + version_descriptor: + required: false + description: "allowed for all" + default: "" + +runs: + using: composite + steps: + - name: add artefact + run: | + nix run . -- \ + add-artefact \ + --state "${RELEASE_ACTION_STATEFILE}" \ + --artefact-type "${{ inputs.type }}" \ + --artefact-repository "${{ inputs.repository }}" \ + --artefact-name "${{ inputs.name }}" \ + --artefact-filename "${{ inputs.filename }}" \ + --artefact-pattern "${{ inputs.pattern }}" \ + --artefact-directory "${{ inputs.directory }}" \ + --version-descriptor "${{ inputs.version_descriptor }}" diff --git a/add-deployment/action.yaml b/add-deployment/action.yaml new file mode 100644 index 0000000..a425cd4 --- /dev/null +++ b/add-deployment/action.yaml @@ -0,0 +1,49 @@ +name: "add deployment to release project" +description: "add additional deployments to initialized project" + +inputs: + type: + required: true + description: "known types: helm_release" + + release_name: + required: true + description: "helm release name" + default: "" + + condition: + required: false + description: | + when should the deployment be updated? + choices: always, never, pre_release_only, release_only + default: "" + + image_paths: + required: false + description: "space separated list of paths to image tags in helm values" + default: "" + + namespace: + required: false + description: "kubernetes namespace of the release" + default: "" + + repository: + required: false + description: "helm chart registry" + default: "" + +runs: + using: composite + steps: + - name: add deployment + run: | + nix run . -- \ + add-deployment \ + --state "${RELEASE_ACTION_STATEFILE}" \ + --deployment-type "${{ inputs.type }}" \ + --deployment-release-name "${{ inputs.release_name }}" \ + --deployment-condition "${{ inputs.condition }}" \ + --deployment-image-paths "${{ inputs.image_paths }}" \ + --deployment-namespace "${{ inputs.namespace }}" \ + --deployment-repository "${{ inputs.repository }}" diff --git a/declare/action.yaml b/declare/action.yaml new file mode 100644 index 0000000..8b4ae34 --- /dev/null +++ b/declare/action.yaml @@ -0,0 +1,138 @@ +name: "declare project for release" +description: "start release configuration by declaring a project" + +inputs: + version_descriptor: + required: true + + configure_runner_environment: + required: false + default: true + + repository: + required: false + default: "${{ github.repository }}" + + artefact_type: + required: false + description: "known types: oci_image, tarball, wheel, sdist, npm" + + artefact_repository: + required: false + description: "allowed for oci_image, tarbal, wheel, sdist" + default: "" + + artefact_name: + required: false + description: "required for oci_image" + default: "" + + artefact_filename: + required: false + description: "required for tarball, sdist" + default: "" + + artefact_pattern: + required: false + description: "required for wheel" + default: "" + + artefact_directory: + required: false + description: "required for npm" + default: "" + + artefact_version_descriptor: + required: false + description: "allowed for all" + default: "" + + deployment_type: + required: false + description: "known types: helm_release" + + deployment_release_name: + required: false + description: "helm release name" + default: "" + + deployment_condition: + required: false + description: | + when should the deployment be updated? + choices: always, never, pre_release_only, release_only + default: "" + + deployment_image_paths: + required: false + description: "space separated list of paths to image tags in helm values" + default: "" + + deployment_namespace: + required: false + description: "kubernetes namespace of the release" + default: "" + + deployment_repository: + required: false + description: "helm chart registry" + default: "" + + +runs: + using: composite + steps: + - if: inputs.configure_runner_environment == 'true' + 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 + run: | + # TODO get current release version + + if [[ "${{ github.ref_name }}" == "master" || "${{ github.ref_name }}" == "main" ]]; then + IS_PRE_RELEASE="0" + else + IS_PRE_RELEASE="1" + fi + + nix run . -- \ + declare \ + --state "${RELEASE_ACTION_STATEFILE}" \ + --version-descriptor "${{ inputs.version_descriptor }}" \ + --release-repository-name "${{ inputs.repository }}" \ + --release-ref-name "${{ github.ref_name }}" \ + --release-run-number "${{ github.run_number }}" \ + --release-commit-sha "${{ github.sha }}" \ + --is-pre-release "${IS_PRE_RELEASE}" \ + + if [[ ! -z "${{ inputs.artefact_type }}" ]]; then + nix run . -- \ + add-artefact \ + --state "${RELEASE_ACTION_STATEFILE}" \ + --artefact-type "${{ inputs.artefact_type }}" \ + --artefact-repository "${{ inputs.artefact_repository }}" \ + --artefact-name "${{ inputs.artefact_name }}" \ + --artefact-filename "${{ inputs.artefact_filename }}" \ + --artefact-pattern "${{ inputs.artefact_pattern }}" \ + --artefact-directory "${{ inputs.artefact_directory }}" \ + --version-descriptor "${{ inputs.artefact_version_descriptor }}" + fi + + if [[ ! -z "${{ inputs.deployment_type }}" ]]; then + nix run . -- \ + add-deployment \ + --state "${RELEASE_ACTION_STATEFILE}" \ + --deployment-type "${{ inputs.deployment_type }}" \ + --deployment-release-name "${{ inputs.deployment_release_name }}" \ + --deployment-condition "${{ inputs.deployment_condition }}" \ + --deployment-image-paths "${{ inputs.deployment_image_paths }}" \ + --deployment-namespace "${{ inputs.deployment_namespace }}" \ + --deployment-repository "${{ inputs.deployment_repository }}" + fi diff --git a/dump/action.yaml b/dump/action.yaml new file mode 100644 index 0000000..df480e7 --- /dev/null +++ b/dump/action.yaml @@ -0,0 +1,11 @@ +name: "dump project description" +description: "dump current project description" + +runs: + using: composite + steps: + - name: dump project description + run: | + nix run . -- \ + dump \ + --state "${RELEASE_ACTION_STATEFILE}" diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..08e416f --- /dev/null +++ b/flake.lock @@ -0,0 +1,61 @@ +{ + "nodes": { + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1764560356, + "narHash": "sha256-M5aFEFPppI4UhdOxwdmceJ9bDJC4T6C6CzCK1E2FZyo=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "6c8f0cca84510cc79e09ea99a299c9bc17d03cb6", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-25.05", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..057b0ba --- /dev/null +++ b/flake.nix @@ -0,0 +1,49 @@ +{ + description = "puzzleYOU release action"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.05"; + flake-utils.url = "github:numtide/flake-utils"; + }; + + outputs = { self, nixpkgs, flake-utils, ... }: + flake-utils.lib.eachDefaultSystem (system: + let + pkgs = import nixpkgs { inherit system; }; + + python = pkgs.python313.withPackages (ps: with ps; [ + isort + flake8 + semver + toml + ]); + + pythonPackage = pkgs.python3Packages.buildPythonPackage { + name = "release-action"; + src = ./.; + }; + in + { + devShells.default = pkgs.mkShell { + buildInputs = [ + pkgs.envsubst + pkgs.just + pkgs.gitea-actions-runner + python + ]; + }; + + packages.default = pkgs.writers.writePython3Bin + "release-action" + { + libraries = with pkgs.python3Packages; [ + semver # TODO move to setup.py? + toml + pythonPackage + ]; + } + (builtins.readFile ./src/main.py) + ; + } + ); +} diff --git a/justfile b/justfile new file mode 100644 index 0000000..0f1321a --- /dev/null +++ b/justfile @@ -0,0 +1,15 @@ +test: test-python test-workflows + +test-python: + python3 src/test.py + flake8 + isort . --check + +test-workflows: + act_runner exec \ + --image "-self-hosted" \ + --event pull_request \ + --workflows ./.gitea/workflows/check.yaml \ + --job test-actions + + # --image "europe-docker.pkg.dev/puzzle-and-play/docker/action-runner-job:latest" \ diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..ab4828e --- /dev/null +++ b/setup.py @@ -0,0 +1,6 @@ +from setuptools import setup + +setup( + name='release-action', + version='0.0.1.dev0', +) diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/main.py b/src/main.py new file mode 100755 index 0000000..c8160ab --- /dev/null +++ b/src/main.py @@ -0,0 +1,291 @@ +import pickle +from argparse import ArgumentParser +from dataclasses import replace +from typing import Optional + +from release import versioning +from release.context import ReleaseContext +from release.project import (ArtefactDescription, DeploymentCondition, + DeploymentDescription, HelmRelease, Npm, OciImage, + ProjectDescription, Sdist, Tarball, Wheel) + + +def load_project_description(filename) -> ProjectDescription: + with open(filename, 'rb') as f: + return pickle.load(f) + + +def save_project_description(filename, project_description): + with open(filename, 'wb') as f: + pickle.dump(project_description, f) + + +def make_project_description( + version_descriptor_filename: str) -> ProjectDescription: + return ProjectDescription(version_descriptor=version_descriptor_filename) + + +def make_context(repository_name: str, + ref_name: str, + run_number: str, + commit_sha: str, + is_pre_release: bool) -> ReleaseContext: + return ReleaseContext(repository_name=repository_name, + ref_name=ref_name, + run_number=run_number, + commit_sha=commit_sha, + is_pre_release=is_pre_release) + + +def dump_project_description(project_description: ProjectDescription): + print('version_descriptor: %s' % project_description.version_descriptor) + print('project version: %s' % + versioning.use_any(project_description.version_descriptor).version) + + print('artefacts:') + for artefact in project_description.artefacts: + generated = artefact.generated + version_descriptor = ( + artefact.version_descriptor + or project_description.version_descriptor) + + if isinstance(generated, OciImage): + print(' - oci image: %s' % generated.name) + print(' repository: %s' % generated.repository) + + elif isinstance(generated, Tarball): + print(' - tarball: %s' % generated.filename) + print(' repository: %s' % generated.repository) + + elif isinstance(generated, Wheel): + print(' - wheel: %s' % generated.pattern) + print(' repository: %s' % generated.repository) + + elif isinstance(generated, Sdist): + print(' - sdist: %s' % generated.filename) + print(' repository: %s' % generated.repository) + + elif isinstance(generated, Npm): + print(' - npm: %s' % generated.directory) + + print(' version_descriptor: %s' % version_descriptor) + print(' artefact version: %s' % + versioning.use_any(version_descriptor).version) + print('') + + print('deployments:') + for desc in project_description.deployments: + deployment = desc.deployment + condition = desc.condition + + if condition == DeploymentCondition.ALWAYS: + condition_str = 'always' + elif condition == DeploymentCondition.NEVER: + condition_str = 'never' + elif condition == DeploymentCondition.PRE_RELEASE_ONLY: + condition_str = 'pre_release_only' + elif condition == DeploymentCondition.RELEASE_ONLY: + condition_str = 'release_only' + + print(' - condition: %s' % condition_str) + + if isinstance(deployment, HelmRelease): + print(' type: helm release') + print(' release name: %s' % deployment.release_name) + print(' image paths: %s' % deployment.image_paths) + print(' namespace: %s' % deployment.namespace) + print(' repository: %s' % deployment.repository) + print('') + + print('context:') + context = project_description.context + print(' repository name: %s' % context.repository_name) + print(' ref name: %s' % context.ref_name) + print(' run number: %s' % context.run_number) + print(' commit sha: %s' % context.commit_sha) + print(' is pre-release: %s' % context.is_pre_release) + + +def make_artefact(type: str, + repository: str, + name: str, + filename: str, + pattern: str, + directory: str, + version_descriptor) -> ArtefactDescription: + + maybe_repository = ({'repository': repository} + if repository is not None + else {}) + + if type == 'oci_image': + assert name is not None + generated = OciImage(name=name, **maybe_repository) + + elif type == 'tarball': + assert filename is not None + generated = Tarball(filename=filename, **maybe_repository) + + elif type == 'wheel': + assert pattern is not None + generated = Wheel(pattern=pattern, **maybe_repository) + + elif type == 'sdist': + assert filename is not None + generated = Sdist(filename=filename, **maybe_repository) + + elif type == 'npm': + assert directory is not None + generated = Npm(directory=directory) + + else: + raise Exception('unknown artefact type: %s' % type) + + return ArtefactDescription( + generated=generated, version_descriptor=version_descriptor) + + +def make_deployment(type: str, + release_name: str, + image_paths: list[str], + namespace: Optional[str], + repository: Optional[str], + condition_str: Optional[str]) -> DeploymentDescription: + + if type == 'helm_release': + maybe_image_paths = ({'image_paths': image_paths} + if len(image_paths) > 0 + else {}) + + maybe_repository = ({'repository': repository} + if repository is not None + else {}) + + deployment = HelmRelease(release_name=release_name, + namespace=(namespace or release_name), + **maybe_image_paths, + **maybe_repository) + else: + raise Exception('unknown deployment type: %s' % type) + + if condition_str == 'always': + condition = DeploymentCondition.ALWAYS + elif condition_str == 'never': + condition = DeploymentCondition.NEVER + elif condition_str == 'pre_release_only': + condition = DeploymentCondition.PRE_RELEASE_ONLY + elif condition_str == 'release_only': + condition = DeploymentCondition.RELEASE_ONLY + elif condition_str is None: + condition = None + else: + raise Exception('unknown condition: %s' % condition_str) + + maybe_condition = ({'condition': condition} + if condition is not None + else {}) + + return DeploymentDescription(deployment=deployment, **maybe_condition) + + +if __name__ == '__main__': + parser = ArgumentParser() + parser.add_argument('action', choices=[ + 'declare', 'add-artefact', 'add-deployment', 'dump']) + + def nullable_string(val): + if not val: + return None + return val + + def space_separated(val): + return list(filter(lambda it: it != '', (val or '').split(' '))) + + def zero_or_one(val): + if val == '0': + return False + elif val == '1': + return True + else: + raise Exception('flag can be "0" or "1". got: %s' % val) + + parser.add_argument('--state', required=True) + parser.add_argument('--version-descriptor', type=nullable_string) + + parser.add_argument('--release-repository-name') + parser.add_argument('--release-ref-name') + parser.add_argument('--release-run-number') + parser.add_argument('--release-commit-sha') + parser.add_argument('--is-pre-release', type=zero_or_one) + + parser.add_argument( + '--artefact-type', type=nullable_string, + choices=['oci_image', 'tarball', 'wheel', 'sdist', 'npm']) + parser.add_argument('--artefact-repository', type=nullable_string) + parser.add_argument('--artefact-name', type=nullable_string) + parser.add_argument('--artefact-filename', type=nullable_string) + parser.add_argument('--artefact-pattern', type=nullable_string) + parser.add_argument('--artefact-directory', type=nullable_string) + + parser.add_argument('--deployment-type', + type=nullable_string, + choices=['helm_release']) + parser.add_argument('--deployment-release-name', type=nullable_string) + parser.add_argument('--deployment-image-paths', type=space_separated) + parser.add_argument('--deployment-namespace', type=nullable_string) + parser.add_argument('--deployment-repository', type=nullable_string) + parser.add_argument('--deployment-condition', type=nullable_string) + + args = parser.parse_args() + + if args.action == 'declare': + project_description = make_project_description(args.version_descriptor) + + project_description = replace( + project_description, + context=make_context(args.release_repository_name, + args.release_ref_name, + args.release_run_number, + args.release_commit_sha, + args.is_pre_release)) + + save_project_description(args.state, project_description) + + elif args.action == 'add-artefact': + project_description = load_project_description(args.state) + + artefact = make_artefact(args.artefact_type, + args.artefact_repository, + args.artefact_name, + args.artefact_filename, + args.artefact_pattern, + args.artefact_directory, + args.version_descriptor) + + project_description = replace( + project_description, + artefacts=project_description.artefacts + [artefact]) + + save_project_description(args.state, project_description) + + elif args.action == 'add-deployment': + project_description = load_project_description(args.state) + + deployment = make_deployment(args.deployment_type, + args.deployment_release_name, + args.deployment_image_paths, + args.deployment_namespace, + args.deployment_repository, + args.deployment_condition) + + project_description = replace( + project_description, + deployments=project_description.deployments + [deployment]) + + save_project_description(args.state, project_description) + + elif args.action == 'dump': + dump_project_description(load_project_description(args.state)) + + else: + raise NotImplementedError() diff --git a/src/release/__init__.py b/src/release/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/release/context.py b/src/release/context.py new file mode 100644 index 0000000..99188d8 --- /dev/null +++ b/src/release/context.py @@ -0,0 +1,10 @@ +from dataclasses import dataclass + + +@dataclass(frozen=True) +class ReleaseContext: + repository_name: str + ref_name: str + run_number: int + commit_sha: str + is_pre_release: bool diff --git a/src/release/project.py b/src/release/project.py new file mode 100644 index 0000000..548d206 --- /dev/null +++ b/src/release/project.py @@ -0,0 +1,78 @@ +from dataclasses import dataclass, field +from enum import Enum +from typing import Optional, Union + +from semver import Version + +from release.context import ReleaseContext + +DEFAULT_OCI_IMAGE_REPOSITORY = 'europe-docker.pkg.dev/puzzle-and-play/helm' +DEFAULT_GITEA_PACKAGE_INSTANCE = 'https://gitea.puzzleyou.net' +DEFAULT_PYPI_REPOSITORY_NAME = 'gitea' + + +@dataclass(frozen=True) +class OciImage: + # NOTE: tag is exported as IMAGE_TAG_$NAME + name: str + repository: str = DEFAULT_OCI_IMAGE_REPOSITORY + + +@dataclass(frozen=True) +class Tarball: + filename: str + repository: str = DEFAULT_GITEA_PACKAGE_INSTANCE + + +@dataclass(frozen=True) +class Wheel: + pattern: str + repository: str = DEFAULT_PYPI_REPOSITORY_NAME + + +@dataclass(frozen=True) +class Sdist: + filename: str + repository: str = DEFAULT_PYPI_REPOSITORY_NAME + + +@dataclass(frozen=True) +class Npm: + directory: str + + +@dataclass(frozen=True) +class ArtefactDescription: + generated: Union[OciImage, Tarball, Wheel, Sdist, Npm] + version_descriptor: Optional[str] = None # if different from main + + +@dataclass(frozen=True) +class HelmRelease: + release_name: str + namespace: str + image_paths: list[str] = field(default_factory=lambda: ['image.tag']) + repository: str = DEFAULT_OCI_IMAGE_REPOSITORY + + +class DeploymentCondition(Enum): + ALWAYS = 0 + NEVER = 1 + PRE_RELEASE_ONLY = 2 + RELEASE_ONLY = 3 + + +@dataclass(frozen=True) +class DeploymentDescription: + deployment: Union[HelmRelease] + condition: DeploymentCondition = DeploymentCondition.PRE_RELEASE_ONLY + + +@dataclass(frozen=True) +class ProjectDescription: + version_descriptor: str # filename + artefacts: list[ArtefactDescription] = field(default_factory=lambda: []) + deployments: list[DeploymentDescription] = field( + default_factory=lambda: []) + context: Optional[ReleaseContext] = None + planned_version: Optional[Version] = None diff --git a/src/release/tests/__init__.py b/src/release/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/release/tests/assets/Cargo.toml b/src/release/tests/assets/Cargo.toml new file mode 100644 index 0000000..d0414c0 --- /dev/null +++ b/src/release/tests/assets/Cargo.toml @@ -0,0 +1,41 @@ +[package] +name = "resi" +version = "0.0.1" +edition = "2021" +description = "webservice for storing and delivering images" + +[dependencies] +async-std.workspace = true +async-trait.workspace = true +serde.workspace = true +derive_more.workspace = true +thiserror.workspace = true +resi-aux.workspace = true +resi-core.workspace = true +resi-filters.workspace = true +resi-utils.workspace = true +resi-import.workspace = true +pyru.workspace = true +anyhow.workspace = true +tokio.workspace = true +futures.workspace = true +hyper.workspace = true +lazy_static.workspace = true +serde_json.workspace = true +tracing-subscriber.workspace = true +tracing.workspace = true +bytes.workspace = true +url.workspace = true +regex.workspace = true +reqwest.workspace = true +tikv-jemallocator.workspace = true +sqlx.workspace = true +log.workspace = true +chrono.workspace = true +yolo-rs.workspace = true +image.workspace = true +arcstr.workspace = true +ndarray.workspace = true +once_cell.workspace = true +ort = "=2.0.0-rc.9" +ort-sys = "=2.0.0-rc.9" diff --git a/src/release/tests/assets/package.json b/src/release/tests/assets/package.json new file mode 100644 index 0000000..c937c4b --- /dev/null +++ b/src/release/tests/assets/package.json @@ -0,0 +1,65 @@ +{ + "name": "product-designer", + "version": "42.13.37", + "description": "", + "license": "", + "scripts": { + "ng": "ng", + "start": "ng serve", + "build": "ng build", + "test": "ng test", + "lint": "ng lint", + "e2e": "ng e2e" + }, + "private": true, + "dependencies": { + "@angular/animations": "^5.0.0", + "@angular/cdk": "^5.0.2", + "@angular/common": "^5.0.0", + "@angular/compiler": "^5.0.0", + "@angular/core": "^5.0.0", + "@angular/forms": "^5.0.0", + "@angular/http": "^5.0.0", + "@angular/material": "^5.0.2", + "@angular/platform-browser": "^5.0.0", + "@angular/platform-browser-dynamic": "^5.0.0", + "@angular/platform-server": "^5.0.0", + "@angular/router": "^5.0.0", + "@iframe-resizer/child": "^5.4.6", + "bowser": "^2.11.0", + "core-js": "^2.5.1", + "crypto-js": "^3.1.9-1", + "dragdroptouch": "github:mychiara/dragdroptouch#master", + "enhanced-resolve": "^3.1.0", + "fastest-levenshtein": "^1.0.16", + "gl-matrix": "^2.6.1", + "hammerjs": "^2.0.8", + "ngx-pagination": "^3.0.1", + "rxjs": "^5.5.2", + "svg.js": "^2.6.3", + "text-encoding": "^0.6.4", + "xmlserializer": "^0.6.0", + "zone.js": "^0.11.4" + }, + "devDependencies": { + "@angular/cli": "~1.7.4", + "@angular/compiler-cli": "^5.2.11", + "@types/jasmine": "2.5.54", + "@types/node": "^7.0.43", + "codelyzer": "~3.0.1", + "jasmine-core": "~5.1.2", + "jasmine-spec-reporter": "~7.0.0", + "karma": "~6.4.3", + "karma-chrome-launcher": "^3.2.0", + "karma-cli": "~2.0.0", + "karma-coverage-istanbul-reporter": "^3.0.3", + "karma-firefox-launcher": "^1.1.0", + "karma-jasmine": "~5.1.0", + "karma-jasmine-html-reporter": "^2.1.0", + "node-sass": "^9.0.0", + "protractor": "~5.1.2", + "ts-node": "~3.0.6", + "tslint": "~5.2.0", + "typescript": "~2.6.1" + } +} diff --git a/src/release/tests/assets/pyproject.toml b/src/release/tests/assets/pyproject.toml new file mode 100644 index 0000000..d1bf5c9 --- /dev/null +++ b/src/release/tests/assets/pyproject.toml @@ -0,0 +1,19 @@ +[build-system] +requires = ["maturin>=1"] +build-backend = "maturin" + +[project] +name = "prngl" +version = "0.0.3" +requires-python = ">=3.7" +classifiers = [ + "Programming Language :: Rust", + "Programming Language :: Python :: Implementation :: CPython", + "Programming Language :: Python :: Implementation :: PyPy", +] +dependencies = [ + 'resi', +] + +[tool.maturin] +python-source = "python" diff --git a/src/release/tests/assets/setup.py b/src/release/tests/assets/setup.py new file mode 100644 index 0000000..3827510 --- /dev/null +++ b/src/release/tests/assets/setup.py @@ -0,0 +1,43 @@ +from setuptools import setup + +setup( + name='papyru', + version='2.10.4', + description=( + 'minimal REST library with OpenAPI-based validation for django'), + author='puzzleYOU GmbH', + author_email='papyru@puzzleyou.net', + url='http://www.puzzleyou.net/', + license='AGPLv3', + platforms=['any'], + packages=[ + 'papyru', + 'papyru.static', + 'papyru.varspool', + 'papyru.varspool.command' + ], + package_data={ + 'papyru.varspool': ['assets/*'], + }, + install_requires=[ + 'Cerberus', + 'Django', + 'jsonschema', + 'pyyaml', + 'requests', + 'lxml', + 'python-dateutil', + ], + classifiers=[ + 'Development Status :: 5 - Production/Stable', + 'Intended Audience :: Developers', + 'Topic :: Internet :: WWW/HTTP', + 'License :: OSI Approved :: GNU Affero General Public License v3', + 'Programming Language :: Python :: 3', + 'Programming Language :: Python :: 3.6', + 'Programming Language :: Python :: 3.7', + ], + scripts=[ + 'bin/generate_jsonschema.py' + ] +) diff --git a/src/release/tests/assets/version.txt b/src/release/tests/assets/version.txt new file mode 100644 index 0000000..31ad645 --- /dev/null +++ b/src/release/tests/assets/version.txt @@ -0,0 +1 @@ +3.14.15 diff --git a/src/release/tests/context.py b/src/release/tests/context.py new file mode 100644 index 0000000..9ab3b6e --- /dev/null +++ b/src/release/tests/context.py @@ -0,0 +1,12 @@ +from unittest import TestCase + +from release.context import ReleaseContext + + +class TestReleaseContext(TestCase): + def test_can_describe_context(self): + ReleaseContext(repository_name='resi', + ref_name='testing', + run_number=42, + is_pre_release=True, + commit_sha='0AB123') diff --git a/src/release/tests/project.py b/src/release/tests/project.py new file mode 100644 index 0000000..0a1d179 --- /dev/null +++ b/src/release/tests/project.py @@ -0,0 +1,126 @@ +from unittest import TestCase + +from release.project import (ArtefactDescription, DeploymentCondition, + DeploymentDescription, HelmRelease, Npm, OciImage, + ProjectDescription, Sdist, Tarball, Wheel) + + +class TestProjectDescription(TestCase): + def test_can_describe_projects(self): + # resi-lib + ProjectDescription( + version_descriptor='src/python/Cargo.toml', + artefacts=[ + ArtefactDescription( + generated=Wheel(pattern='./scratch/wheels/*.whl')) + ]) + + # productdesignerd + ProjectDescription( + version_descriptor='bootstrap/setup.py', + artefacts=[ + ArtefactDescription( + generated=OciImage(name='productdesignerd') + )], + deployments=[ + DeploymentDescription( + condition=DeploymentCondition.PRE_RELEASE_ONLY, + deployment=HelmRelease( + release_name='productdesignerd-testing', + namespace='productdesignerd-testing')), + ]) + + # masa-images + ProjectDescription( + version_descriptor='Cargo.toml', + artefacts=[ + ArtefactDescription( + generated=OciImage(name='masa-images')), + ArtefactDescription( + generated=Wheel(pattern='./scratch/wheels/*.whl')) + ], + deployments=[ + DeploymentDescription( + condition=DeploymentCondition.PRE_RELEASE_ONLY, + deployment=HelmRelease( + release_name='masa-images-testing', + namespace='masa-images-testing')), + ]) + + # yaac + ProjectDescription( + version_descriptor='version.txt', + artefacts=[ + ArtefactDescription(generated=Tarball('/tmp/yaac.tar.gz')) + ]) + + # papyru + ProjectDescription( + version_descriptor='setup.py', + artefacts=[ + ArtefactDescription( + generated=Sdist( + './dist/papyru-${PAPYRU_PACKAGE_VERSION}.tar.gz')) + ]) + + # productdesigner + ProjectDescription( + version_descriptor='package.json', + artefacts=[ + ArtefactDescription( + generated=Npm('dist/browser')), + ArtefactDescription( + generated=OciImage(name='productdesigner')), + ], + deployments=[ + DeploymentDescription( + condition=DeploymentCondition.PRE_RELEASE_ONLY, + deployment=HelmRelease( + release_name='productdesigner-testing', + namespace='productdesigner-testing')) + ]) + + # motacilla + ProjectDescription( + version_descriptor='setup.py', + artefacts=[ + ArtefactDescription(generated=OciImage(name='motacilla')), + ArtefactDescription(generated=OciImage(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'])), + ]) + + # prngl + ProjectDescription( + version_descriptor='Cargo.toml', + artefacts=[ + ArtefactDescription( + version_descriptor='src/python/pyproject.toml', + generated=Wheel(pattern='./scratch/wheels/*.whl')), + ArtefactDescription(generated=OciImage(name='prngl')), + ], + deployments=[ + DeploymentDescription( + condition=DeploymentCondition.PRE_RELEASE_ONLY, + deployment=HelmRelease( + release_name='prngl-testing', + namespace='prngl-testing')) + ]) diff --git a/src/release/tests/versioning.py b/src/release/tests/versioning.py new file mode 100644 index 0000000..60d6c53 --- /dev/null +++ b/src/release/tests/versioning.py @@ -0,0 +1,66 @@ +from os import path +from tempfile import NamedTemporaryFile +from unittest import TestCase + +from semver import Version + +from release.versioning import use_any + + +def _asset_path(filename): + return path.join(path.dirname(__file__), 'assets/', filename) + + +def _test_can_read_version(test, filename, expected): + v = use_any(_asset_path(filename)) + test.assertEqual(v.version, expected) + + +def _test_can_write_version(test, filename): + v = use_any(_asset_path(filename)) + v.version = Version(1, 33, 7) + + with NamedTemporaryFile() as tf: + v.store(tf.name) + v2 = use_any(tf.name) + test.assertEqual(v2.version, Version(1, 33, 7)) + + +class TestSetupPy(TestCase): + def test_can_read_version(self): + _test_can_read_version(self, 'setup.py', Version(2, 10, 4)) + + def test_can_write_version(self): + _test_can_write_version(self, 'setup.py') + + +class TestCargoToml(TestCase): + def test_can_read_version(self): + _test_can_read_version(self, 'Cargo.toml', Version(0, 0, 1)) + + def test_can_write_version(self): + _test_can_write_version(self, 'Cargo.toml') + + +class TestPyProjectToml(TestCase): + def test_can_read_version(self): + _test_can_read_version(self, 'pyproject.toml', Version(0, 0, 3)) + + def test_can_write_version(self): + _test_can_write_version(self, 'pyproject.toml') + + +class TestPackageJson(TestCase): + def test_can_read_version(self): + _test_can_read_version(self, 'package.json', Version(42, 13, 37)) + + def test_can_write_version(self): + _test_can_write_version(self, 'package.json') + + +class TestVersionTxt(TestCase): + def test_can_read_version(self): + _test_can_read_version(self, 'version.txt', Version(3, 14, 15)) + + def test_can_write_version(self): + _test_can_write_version(self, 'version.txt') diff --git a/src/release/versioning.py b/src/release/versioning.py new file mode 100644 index 0000000..4d821d1 --- /dev/null +++ b/src/release/versioning.py @@ -0,0 +1,110 @@ +import json +import re +from copy import deepcopy +from logging import getLogger + +import toml +from semver import Version + +logger = getLogger(__name__) +RE_SETUP_PY = re.compile(r'version\s?=\s?[\'"](.*)[\'"]') + + +class SetupPy: + def __init__(self, filename): + logger.warning('setup.py is discouraged. Use pyproject.toml.') + + with open(filename, 'r') as f: + content = f.read() + + version_string = RE_SETUP_PY.search(content) + + if version_string is None: + raise Exception('could not find version in %s' % filename) + + self.filename = filename + self.content = content + self.version = Version.parse(version_string.group(1)) + + def store(self, destination=None): + destination = destination or self.filename + edited = RE_SETUP_PY.sub( + 'version=\'%s\'' % self.version, self.content) + + with open(destination, 'w') as f: + f.write(edited) + + +class Structured: + def __init__(self, filename, format, item_path): + with open(filename, 'r') as f: + obj = format.load(f) + + cur = obj + for part in item_path: + cur = cur[part] + + self.format = format + self.item_path = item_path + self.content = obj + self.version = Version.parse(cur) + + def store(self, destination=None): + destination = destination or self.filename + edited = deepcopy(self.content) + + cur = edited + for part in self.item_path[:-1]: + cur = cur[part] + + cur[self.item_path[-1]] = str(self.version) + + with open(destination, 'w') as f: + self.format.dump(edited, f) + + +class VersionTxt: + def __init__(self, filename): + self.filename = filename + + with open(filename, 'r') as f: + self.version = Version.parse(f.read()) + + def store(self, destination=None): + destination = destination or self.filename + with open(destination, 'w') as f: + f.write(str(self.version)) + + +def use_pyproject_toml(filename): + return Structured(filename, toml, ['project', 'version']) + + +def use_cargo_toml(filename): + return Structured(filename, toml, ['package', 'version']) + + +def use_package_json(filename): + return Structured(filename, json, ['version']) + + +def use_setup_py(filename): + return SetupPy(filename) + + +def use_version_txt(filename): + return VersionTxt(filename) + + +def use_any(filename): + for f in [use_pyproject_toml, + use_cargo_toml, + use_package_json, + use_version_txt, + use_setup_py]: + try: + return f(filename) + except Exception: + pass + + raise Exception('cannot detect format of %s' % filename) diff --git a/src/test.py b/src/test.py new file mode 100755 index 0000000..90143a1 --- /dev/null +++ b/src/test.py @@ -0,0 +1,10 @@ +#!/usr/bin/env python3 +import logging +import unittest + +logging.basicConfig(level=logging.ERROR) +from release.tests.context import * # noqa +from release.tests.project import * # noqa +from release.tests.versioning import * # noqa + +unittest.main() diff --git a/test-assets/Cargo.toml b/test-assets/Cargo.toml new file mode 100644 index 0000000..d0414c0 --- /dev/null +++ b/test-assets/Cargo.toml @@ -0,0 +1,41 @@ +[package] +name = "resi" +version = "0.0.1" +edition = "2021" +description = "webservice for storing and delivering images" + +[dependencies] +async-std.workspace = true +async-trait.workspace = true +serde.workspace = true +derive_more.workspace = true +thiserror.workspace = true +resi-aux.workspace = true +resi-core.workspace = true +resi-filters.workspace = true +resi-utils.workspace = true +resi-import.workspace = true +pyru.workspace = true +anyhow.workspace = true +tokio.workspace = true +futures.workspace = true +hyper.workspace = true +lazy_static.workspace = true +serde_json.workspace = true +tracing-subscriber.workspace = true +tracing.workspace = true +bytes.workspace = true +url.workspace = true +regex.workspace = true +reqwest.workspace = true +tikv-jemallocator.workspace = true +sqlx.workspace = true +log.workspace = true +chrono.workspace = true +yolo-rs.workspace = true +image.workspace = true +arcstr.workspace = true +ndarray.workspace = true +once_cell.workspace = true +ort = "=2.0.0-rc.9" +ort-sys = "=2.0.0-rc.9" diff --git a/test-assets/version.txt b/test-assets/version.txt new file mode 100644 index 0000000..1304b01 --- /dev/null +++ b/test-assets/version.txt @@ -0,0 +1 @@ +1.33.7 diff --git a/version.txt b/version.txt new file mode 100644 index 0000000..8acdd82 --- /dev/null +++ b/version.txt @@ -0,0 +1 @@ +0.0.1