-
Notifications
You must be signed in to change notification settings - Fork 1
Release Action #160
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Release Action #160
Changes from 14 commits
262ce21
1a31eae
65740cd
9542d22
9b0647c
29d924b
58bdc5f
ab776cf
edfd72a
2c67024
3dafd0d
57edf38
8c330bb
a1d0b2c
fa6421f
d49c680
5ccd8cd
a78736b
fcbae62
c79df7b
e67b64b
a3e4710
e98b143
fe51c4d
78d8627
08de9a2
7716b94
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,46 @@ | ||
| import argparse | ||
| import json | ||
| import tomllib | ||
| from pathlib import Path | ||
|
|
||
|
|
||
| def extract_version(file_path: Path | str) -> str: | ||
| path = Path(file_path) | ||
|
|
||
| if path.name == "package.json": | ||
| data = json.loads(path.read_text()) | ||
| if version := data.get("version"): | ||
| return version | ||
| raise KeyError(f"No version field found in {path!r}") | ||
|
|
||
| if path.name == "pyproject.toml": | ||
| with path.open("rb") as f: | ||
| data = tomllib.load(f) | ||
| project = data.get("project", {}) | ||
| if version := project.get("version"): | ||
| return version | ||
| tool = data.get("tool", {}) | ||
| if version := tool.get("poetry", {}).get("version"): | ||
| return version | ||
| raise KeyError(f"No version field found in {path!r}") | ||
|
|
||
| raise ValueError(f"Unsupported file type {path.name!r}; expected pyproject.toml or package.json") | ||
|
|
||
|
|
||
| def main() -> None: | ||
| parser = argparse.ArgumentParser( | ||
| description="Extract the version from a pyproject.toml or package.json file and print it." | ||
| ) | ||
| _ = parser.add_argument( | ||
| "file", | ||
| nargs="?", | ||
| default="pyproject.toml", | ||
| help="Path to pyproject.toml or package.json (default: pyproject.toml)", | ||
| ) | ||
| args = parser.parse_args() | ||
|
|
||
| print(extract_version(args.file)) # noqa: T201 # specifically printing this out so CI pipelines can read the value from stdout | ||
|
|
||
|
|
||
| if __name__ == "__main__": | ||
| main() |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,58 @@ | ||||||||||||||||||||||||||||||||||||||
| import argparse | ||||||||||||||||||||||||||||||||||||||
| import subprocess | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| def ensure_tag_not_present(tag: str, remote: str) -> None: | ||||||||||||||||||||||||||||||||||||||
| try: | ||||||||||||||||||||||||||||||||||||||
| _ = subprocess.run( # noqa: S603 # this is trusted input, it's our own arguments being passed in | ||||||||||||||||||||||||||||||||||||||
| ["git", "ls-remote", "--exit-code", "--tags", remote, f"refs/tags/{tag}"], # noqa: S607 # if `git` isn't in PATH already, then there are bigger problems to solve | ||||||||||||||||||||||||||||||||||||||
| check=True, | ||||||||||||||||||||||||||||||||||||||
| stdout=subprocess.DEVNULL, | ||||||||||||||||||||||||||||||||||||||
| stderr=subprocess.DEVNULL, | ||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||
| raise Exception(f"Error: tag '{tag}' exists on remote '{remote}'") # noqa: TRY002 # not worth a custom exception | ||||||||||||||||||||||||||||||||||||||
| except subprocess.CalledProcessError: | ||||||||||||||||||||||||||||||||||||||
| # tag not present, continue | ||||||||||||||||||||||||||||||||||||||
| return | ||||||||||||||||||||||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧹 Nitpick | 🔵 Trivial | 💤 Low value Consider keyword-only parameters for clarity. The ♻️ Proposed refactor-def ensure_tag_not_present(tag: str, remote: str) -> None:
+def ensure_tag_not_present(*, tag: str, remote: str) -> None:Then update call sites: - ensure_tag_not_present(tag, args.remote)
+ ensure_tag_not_present(tag=tag, remote=args.remote)And: - ensure_tag_not_present(tag, args.remote)
+ ensure_tag_not_present(tag=tag, remote=args.remote)🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| def main() -> None: | ||||||||||||||||||||||||||||||||||||||
| parser = argparse.ArgumentParser( | ||||||||||||||||||||||||||||||||||||||
| description=("Confirm that git tag v<version> is not present on a remote, or create and push the tag.") | ||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||
| _ = parser.add_argument( | ||||||||||||||||||||||||||||||||||||||
| "--version", | ||||||||||||||||||||||||||||||||||||||
| required=True, | ||||||||||||||||||||||||||||||||||||||
| help="Version string (e.g. 1.0.6); the tag will be v<version>", | ||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||
| _ = parser.add_argument( | ||||||||||||||||||||||||||||||||||||||
| "--confirm-tag-not-present", | ||||||||||||||||||||||||||||||||||||||
| action="store_true", | ||||||||||||||||||||||||||||||||||||||
| help="Check that git tag v<version> is NOT present on the remote. If the tag exists, exit with an error.", | ||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||
| _ = parser.add_argument( | ||||||||||||||||||||||||||||||||||||||
| "--push-tag-to-remote", | ||||||||||||||||||||||||||||||||||||||
| action="store_true", | ||||||||||||||||||||||||||||||||||||||
| help="Create git tag v<version> locally and push it to the remote. Internally confirms the tag is not already present.", | ||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||
| _ = parser.add_argument( | ||||||||||||||||||||||||||||||||||||||
| "--remote", | ||||||||||||||||||||||||||||||||||||||
| default="origin", | ||||||||||||||||||||||||||||||||||||||
| help="Name of git remote to query/push (default: origin)", | ||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||
| args = parser.parse_args() | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| tag = args.version if args.version.startswith("v") else f"v{args.version}" | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| if args.push_tag_to_remote: | ||||||||||||||||||||||||||||||||||||||
| ensure_tag_not_present(tag, args.remote) | ||||||||||||||||||||||||||||||||||||||
| _ = subprocess.run(["git", "tag", tag], check=True) # noqa: S603,S607 # this is trusted input, it's our own pyproject.toml file. and if `git` isn't in PATH, then there are larger problems anyway | ||||||||||||||||||||||||||||||||||||||
| _ = subprocess.run(["git", "push", args.remote, tag], check=True) # noqa: S603,S607 # this is trusted input, it's our own pyproject.toml file. and if `git` isn't in PATH, then there are larger problems anyway | ||||||||||||||||||||||||||||||||||||||
| return | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| if args.confirm_tag_not_present: | ||||||||||||||||||||||||||||||||||||||
| ensure_tag_not_present(tag, args.remote) | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+49
to
+57
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧹 Nitpick | 🔵 Trivial | ⚡ Quick win Add success logging for workflow visibility. The script completes silently on success. In CI logs, explicit confirmation messages improve debugging and auditability. 📊 Proposed enhancement if args.push_tag_to_remote:
ensure_tag_not_present(tag, args.remote)
_ = subprocess.run(["git", "tag", tag], check=True) # noqa: S603,S607 # this is trusted input, it's our own pyproject.toml file. and if `git` isn't in PATH, then there are larger problems anyway
_ = subprocess.run(["git", "push", args.remote, tag], check=True) # noqa: S603,S607 # this is trusted input, it's our own pyproject.toml file. and if `git` isn't in PATH, then there are larger problems anyway
+ print(f"✓ Tag {tag} created and pushed to {args.remote}")
return
if args.confirm_tag_not_present:
ensure_tag_not_present(tag, args.remote)
+ print(f"✓ Tag {tag} confirmed not present on {args.remote}")📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| if __name__ == "__main__": | ||||||||||||||||||||||||||||||||||||||
| main() | ||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,165 @@ | ||
| {% raw %}name: Release | ||
|
|
||
| on: | ||
| workflow_dispatch: | ||
| inputs: | ||
| dry_run: | ||
| description: "Dry run (build only, skip release)" | ||
| type: boolean | ||
| default: false | ||
|
|
||
| env: | ||
| PYTHONUNBUFFERED: True | ||
|
|
||
| concurrency: | ||
| group: release | ||
| cancel-in-progress: false | ||
|
|
||
| jobs: | ||
| guard: | ||
| runs-on: {% endraw %}{{ gha_linux_runner }}{% raw %} | ||
| timeout-minutes: {% endraw %}{{ gha_short_timeout_minutes }}{% raw %} | ||
| permissions: | ||
| contents: read # needed for checkout and git ls-remote | ||
| statuses: read # needed to read required-check commit status | ||
| actions: read # needed to query CI workflow runs | ||
| outputs: | ||
| version: ${{ steps.get-version.outputs.version }} | ||
| ci-run-id: ${{ steps.get-ci-run-id.outputs.ci-run-id }} | ||
| docker-image-tag: ${{ steps.set-docker-image-tag.outputs.docker-image-tag }} # empty on dry run to skip ECR tag push | ||
| steps: | ||
| - name: Fail if real release is not on main | ||
| if: ${{ !inputs.dry_run && github.ref != 'refs/heads/main' }} | ||
| run: | | ||
| echo "Real releases must run from main. Current ref: ${{ github.ref }}" | ||
| exit 1 | ||
| - name: Check required-check CI status for this commit | ||
| env: | ||
| GH_TOKEN: ${{ github.token }} | ||
| run: | | ||
| STATUS=$(gh api "/repos/${{ github.repository }}/commits/${{ github.sha }}/statuses" --jq '[.[] | select(.context == "required-check")] | first | .state // "not found"') | ||
| echo "required-check status for ${{ github.sha }}: $STATUS" | ||
| if [ "$STATUS" != "success" ] && [ "${{ inputs.dry_run }}" != "true" ]; then | ||
| echo "Wait for CI to pass before releasing." | ||
| exit 1 | ||
| fi | ||
|
Comment on lines
+53
to
+65
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: # Find the file and examine its content
find . -name "release.yaml.jinja" -type fRepository: LabAutomationAndScreening/copier-nuxt-python-intranet-app Length of output: 149 🌐 Web query:
💡 Result: The default behavior of Citations:
🏁 Script executed: cat -n ./template/.github/workflows/release.yaml.jinja | sed -n '40,75p'Repository: LabAutomationAndScreening/copier-nuxt-python-intranet-app Length of output: 2566 🏁 Script executed: # Check the full guard job context to understand the flow
cat -n ./template/.github/workflows/release.yaml.jinja | sed -n '19,95p'Repository: LabAutomationAndScreening/copier-nuxt-python-intranet-app Length of output: 4596
Fetch all pages (or at least bump 🔧 Suggested fix- JOBS=$(gh api "/repos/${{ github.repository }}/actions/runs/${{ steps.get-ci-run-id.outputs.ci-run-id }}/jobs")
+ JOBS=$(gh api --paginate "/repos/${{ github.repository }}/actions/runs/${{ steps.get-ci-run-id.outputs.ci-run-id }}/jobs?per_page=100" \
+ | jq -s '{jobs: [.[].jobs[]]}')🤖 Prompt for AI Agents |
||
| - name: Checkout code | ||
| uses: actions/checkout@{% endraw %}{{ gha_checkout }}{% raw %} | ||
| - name: Extract project version | ||
| id: get-version | ||
| run: | | ||
| VERSION=$(python3 .github/workflows/extract_project_version.py {% endraw %}{% if has_backend %}{% raw %}backend/pyproject.toml{% endraw %}{% else %}{% raw %}frontend/package.json{% endraw %}{% endif %}{% raw %}) | ||
| echo "Project version: $VERSION" | ||
| echo "version=$VERSION" >> $GITHUB_OUTPUT | ||
| - name: Get CI run ID for this commit | ||
| id: get-ci-run-id | ||
| env: | ||
| GH_TOKEN: ${{ github.token }} | ||
| run: | | ||
| RUN_ID=$(gh api "/repos/${{ github.repository }}/actions/workflows/ci.yaml/runs?head_sha=${{ github.sha }}&status=success" --jq '.workflow_runs[0].id') | ||
| if [ "$RUN_ID" = "null" ] || [ -z "$RUN_ID" ]; then | ||
| echo "No successful CI run found for ${{ github.sha }}. Wait for CI to complete before releasing." | ||
| exit 1 | ||
| fi | ||
| echo "ci-run-id=$RUN_ID" >> $GITHUB_OUTPUT | ||
| - name: Check version tag does not already exist | ||
| run: | | ||
| VERSION="${{ steps.get-version.outputs.version }}" | ||
| if python3 .github/workflows/git_tag.py --version "$VERSION" --confirm-tag-not-present; then | ||
| echo "Tag v$VERSION does not exist yet." | ||
| else | ||
| if [ "${{ inputs.dry_run }}" = "true" ]; then | ||
| echo "Tag v$VERSION already exists (dry run, continuing)." | ||
| else | ||
| echo "Tag v$VERSION already exists. Bump the version before releasing." | ||
| exit 1 | ||
| fi | ||
| fi | ||
| - name: Set Docker image tag | ||
| id: set-docker-image-tag | ||
| run: | | ||
| if [ "${{ inputs.dry_run }}" = "true" ]; then | ||
| echo "docker-image-tag=" >> $GITHUB_OUTPUT | ||
| else | ||
| echo "docker-image-tag=${{ steps.get-version.outputs.version }}" >> $GITHUB_OUTPUT | ||
| fi | ||
|
|
||
| {% endraw %}{% if not deploy_as_executable and push_to_ecr %}{% raw %} | ||
| tag-frontend-image: | ||
| name: Tag Frontend Image | ||
| needs: | ||
| - guard | ||
| uses: ./.github/workflows/build-docker-image.yaml | ||
| permissions: | ||
| contents: write # needed for mutex | ||
| id-token: write # needed to assume OIDC roles for ECR push | ||
| with: | ||
| context: ./frontend | ||
| build-contexts: repo-root=. | ||
| additional-hash-paths: pnpm-workspace.yaml pnpm-lock.yaml | ||
| repository: {% endraw %}{{ frontend_ecr_base_url }}{% raw %} | ||
| image_name: {% endraw %}{{ frontend_image_name }}{% raw %} | ||
| push-role-name: {% endraw %}{{ frontend_ecr_push_role_name }}{% raw %} | ||
| release-tag: ${{ needs.guard.outputs.docker-image-tag }} | ||
|
|
||
|
Comment on lines
+96
to
+113
|
||
| {% endraw %}{% if has_backend %}{% raw %} | ||
| tag-backend-image: | ||
| name: Tag Backend Image | ||
| needs: | ||
| - guard | ||
| uses: ./.github/workflows/build-docker-image.yaml | ||
| permissions: | ||
| contents: write # needed for mutex | ||
| id-token: write # needed to assume OIDC roles for ECR push | ||
| with: | ||
| context: ./backend | ||
| repository: {% endraw %}{{ backend_ecr_base_url }}{% raw %} | ||
| image_name: {% endraw %}{{ backend_image_name }}{% raw %} | ||
| push-role-name: {% endraw %}{{ backend_ecr_push_role_name }}{% raw %} | ||
| release-tag: ${{ needs.guard.outputs.docker-image-tag }} | ||
|
|
||
| {% endraw %}{% endif %}{% endif %}{% raw %} | ||
| release: | ||
| name: Create GitHub Release | ||
| runs-on: {% endraw %}{{ gha_linux_runner }}{% raw %} | ||
| timeout-minutes: {% endraw %}{{ gha_short_timeout_minutes }}{% raw %} | ||
| needs: | ||
| - guard{% endraw %}{% if not deploy_as_executable and push_to_ecr %}{% raw %} | ||
| - tag-frontend-image{% endraw %}{% if has_backend %}{% raw %} | ||
| - tag-backend-image{% endraw %}{% endif %}{% endif %}{% raw %} | ||
| permissions: | ||
| contents: write # needed to create GitHub releases | ||
| actions: read # needed to download artifacts from CI runs | ||
| steps:{% endraw %}{% if deploy_as_executable %}{% raw %} | ||
| {% endraw %}{% set os_runners = [gha_linux_runner] + ([gha_windows_runner] if use_windows_in_ci else []) %}{% for runner in os_runners %}{% set ext = "tar" if runner == gha_linux_runner else "zip" %}{% raw %} | ||
| - name: Download executable artifact ({% endraw %}{{ runner }}{% raw %}) | ||
| uses: actions/download-artifact@{% endraw %}{{ gha_download_artifact }}{% raw %} | ||
| with: | ||
| name: built-{% endraw %}{{ repo_name }}-{{ runner }}{% raw %} | ||
| path: artifacts/{% endraw %}{{ runner }}{% raw %} | ||
| run-id: ${{ needs.guard.outputs.ci-run-id }} | ||
| github-token: ${{ github.token }} | ||
| - name: Prepare release asset ({% endraw %}{{ runner }}{% raw %}) | ||
| run: {% endraw %}{% if runner == gha_linux_runner %}mv artifacts/{{ runner }}/app.tar {{ repo_name }}-{{ runner }}-v{% raw %}${{ needs.guard.outputs.version }}{% endraw %}.{{ ext }}{% else %}(cd artifacts/{{ runner }} && zip -r ../../{{ repo_name }}-{{ runner }}-v{% raw %}${{ needs.guard.outputs.version }}{% endraw %}.{{ ext }} .){% endif %} | ||
| {% endfor %}{% endif %}{% if has_circuit_python_backend_template_been_instantiated %}{% raw %} | ||
| - name: Download firmware artifact | ||
| uses: actions/download-artifact@{% endraw %}{{ gha_download_artifact }}{% raw %} | ||
| with: | ||
| name: {% endraw %}{{ repo_name.removesuffix('-driver') }}{% raw %}-firmware-v${{ needs.guard.outputs.version }} | ||
| path: artifacts/firmware | ||
| run-id: ${{ needs.guard.outputs.ci-run-id }} | ||
| github-token: ${{ github.token }} | ||
| - name: Package firmware for release | ||
| run: zip -r {% endraw %}{{ repo_name.removesuffix('-driver') }}{% raw %}-firmware-v${{ needs.guard.outputs.version }}.zip artifacts/firmware/ | ||
| {% endraw %}{% endif %}{% raw %} | ||
| - name: Create GitHub Release | ||
| if: ${{ !inputs.dry_run }} | ||
| uses: softprops/action-gh-release@{% endraw %}{{ gha_action_gh_release }}{% raw %} | ||
| with: | ||
| tag_name: v${{ needs.guard.outputs.version }} | ||
| name: {% endraw %}{{ repo_name }}{% raw %} v${{ needs.guard.outputs.version }} | ||
| generate_release_notes: true{% endraw %}{% if deploy_as_executable or has_circuit_python_backend_template_been_instantiated %}{% raw %} | ||
| fail_on_unmatched_files: true | ||
| files: |{% endraw %}{% if deploy_as_executable %}{% for runner in ([gha_linux_runner] + ([gha_windows_runner] if use_windows_in_ci else [])) %}{% set ext = "tar" if runner == gha_linux_runner else "zip" %} | ||
| {{ repo_name }}-{{ runner }}-v{% raw %}${{ needs.guard.outputs.version }}{% endraw %}.{{ ext }}{% endfor %}{% endif %}{% if has_circuit_python_backend_template_been_instantiated %} | ||
| {{ repo_name.removesuffix('-driver') }}-firmware-v{% raw %}${{ needs.guard.outputs.version }}{% endraw %}.zip{% endif %}{% endif %} | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🌐 Web query:
Does softprops/action-gh-release v3.0.0 exist and what are the breaking changes from v2.2.1?💡 Result:
Yes, softprops/action-gh-release v3.0.0 exists, released on 2026-04-12.[1][2][3] The primary breaking change from v2.2.1 (and the v2 series) to v3.0.0 is the upgrade of the action runtime from Node 20 to Node 24, requiring GitHub-hosted runners or self-hosted fleets that support Node 24. Users still needing Node 20 compatibility should remain on v2.6.2.[1][2][4][5] No other breaking changes to inputs, outputs, or behavior are mentioned in the v3.0.0 release notes; it primarily updates the runtime, Node types, and tag management.[1][4]
Citations:
Confirm Node 24 compatibility before updating to softprops/action-gh-release v3.0.0
The update from v2.2.1 to v3.0.0 includes a breaking change: the runtime upgrades from Node 20 to Node 24. Ensure your GitHub-hosted runners or self-hosted fleet support Node 24; otherwise, remain on v2.6.2 for Node 20 compatibility.
🤖 Prompt for AI Agents