Skip to content
Merged
Show file tree
Hide file tree
Changes from 23 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .copier-answers.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# Changes here will be overwritten by Copier
_commit: v0.0.120
_commit: v0.0.120-13-g22d6174
_src_path: gh:LabAutomationAndScreening/copier-base-template.git
description: A web app that is hosted within a local intranet. Nuxt frontend, python
backend, docker-compose
Expand All @@ -15,6 +15,7 @@ ssh_port_number: 15173
template_might_want_to_install_aws_ssm_port_forwarding_plugin: true
template_might_want_to_use_python_asyncio: true
template_might_want_to_use_vcrpy: true
template_publishes_releases: true
template_uses_javascript: true
template_uses_pulumi: false
template_uses_python: true
Expand Down
40 changes: 35 additions & 5 deletions .github/reusable_workflows/build-docker-image.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@ on:
type: boolean
required: false
default: false
release-tag:
description: 'Optional release tag (e.g. 1.0.6) to push to ECR; applied even if an image with the same build-context-hash already exists'
type: string
required: false
default: ''
context:
description: 'Build context path'
type: string
Expand Down Expand Up @@ -155,16 +160,41 @@ jobs:
- name: Add git sha tag
if: ${{ inputs.push-role-name != 'no-push' }}
run: |
aws ecr batch-get-image --registry-id=${{ steps.parse_ecr_url.outputs.aws_account_id }} --repository-name=${{ inputs.image_name }} --image-ids imageTag=${{ steps.calculate-build-context-hash.outputs.build_context_tag }} --query 'images[].imageManifest' --output text > manifest.json
aws ecr put-image --registry-id=${{ steps.parse_ecr_url.outputs.aws_account_id }} --repository-name=${{ inputs.image_name }} --image-tag git-sha-${{ github.sha }} --image-manifest file://manifest.json
GIT_SHA_TAG="git-sha-${{ github.sha }}"
if aws ecr describe-images --region ${{ steps.parse_ecr_url.outputs.aws_region }} --registry-id=${{ steps.parse_ecr_url.outputs.aws_account_id }} --repository-name=${{ inputs.image_name }} --image-ids imageTag="$GIT_SHA_TAG" > /dev/null 2>&1; then
echo "Tag $GIT_SHA_TAG already exists in ECR, skipping"
else
aws ecr batch-get-image --registry-id=${{ steps.parse_ecr_url.outputs.aws_account_id }} --repository-name=${{ inputs.image_name }} --image-ids imageTag=${{ steps.calculate-build-context-hash.outputs.build_context_tag }} --query 'images[].imageManifest' --output text > manifest.json
aws ecr put-image --registry-id=${{ steps.parse_ecr_url.outputs.aws_account_id }} --repository-name=${{ inputs.image_name }} --image-tag "$GIT_SHA_TAG" --image-manifest file://manifest.json
fi

- name: Add tag for Production
if: ${{ inputs.push-role-name != 'no-push' && inputs.tag-for-production }}
run: |
aws ecr batch-get-image --registry-id=${{ steps.parse_ecr_url.outputs.aws_account_id }} --repository-name=${{ inputs.image_name }} --image-ids imageTag=${{ steps.calculate-build-context-hash.outputs.build_context_tag }} --query 'images[].imageManifest' --output text > manifest.json
# TODO: figure out some better conditional logic about adding a tag for the context in production, so we don't have to `|| true` at the end
aws ecr put-image --registry-id=${{ steps.parse_ecr_url.outputs.aws_account_id }} --repository-name=${{ inputs.image_name }} --image-tag production--${{ steps.calculate-build-context-hash.outputs.build_context_tag }} --image-manifest file://manifest.json || true
aws ecr put-image --registry-id=${{ steps.parse_ecr_url.outputs.aws_account_id }} --repository-name=${{ inputs.image_name }} --image-tag production--git-sha-${{ github.sha }} --image-manifest file://manifest.json
PRODUCTION_CONTEXT_TAG="production--${{ steps.calculate-build-context-hash.outputs.build_context_tag }}"
if aws ecr describe-images --region ${{ steps.parse_ecr_url.outputs.aws_region }} --registry-id=${{ steps.parse_ecr_url.outputs.aws_account_id }} --repository-name=${{ inputs.image_name }} --image-ids imageTag="$PRODUCTION_CONTEXT_TAG" > /dev/null 2>&1; then
echo "Tag $PRODUCTION_CONTEXT_TAG already exists in ECR, skipping"
else
aws ecr put-image --registry-id=${{ steps.parse_ecr_url.outputs.aws_account_id }} --repository-name=${{ inputs.image_name }} --image-tag "$PRODUCTION_CONTEXT_TAG" --image-manifest file://manifest.json
fi
PRODUCTION_SHA_TAG="production--git-sha-${{ github.sha }}"
if aws ecr describe-images --region ${{ steps.parse_ecr_url.outputs.aws_region }} --registry-id=${{ steps.parse_ecr_url.outputs.aws_account_id }} --repository-name=${{ inputs.image_name }} --image-ids imageTag="$PRODUCTION_SHA_TAG" > /dev/null 2>&1; then
echo "Tag $PRODUCTION_SHA_TAG already exists in ECR, skipping"
else
aws ecr put-image --registry-id=${{ steps.parse_ecr_url.outputs.aws_account_id }} --repository-name=${{ inputs.image_name }} --image-tag "$PRODUCTION_SHA_TAG" --image-manifest file://manifest.json
fi

- name: Add release tag
if: ${{ inputs.push-role-name != 'no-push' && inputs.release-tag != '' }}
run: |
RELEASE_TAG="${{ inputs.release-tag }}"
if ! [[ "$RELEASE_TAG" =~ ^[a-zA-Z0-9._-]+$ ]]; then
echo "Invalid release-tag: '$RELEASE_TAG'. Only alphanumeric characters, hyphens, underscores, and dots are allowed."
exit 1
fi
aws ecr batch-get-image --registry-id=${{ steps.parse_ecr_url.outputs.aws_account_id }} --repository-name=${{ inputs.image_name }} --image-ids imageTag=${{ steps.calculate-build-context-hash.outputs.build_context_tag }} --query 'images[].imageManifest' --output text > manifest.json
aws ecr put-image --registry-id=${{ steps.parse_ecr_url.outputs.aws_account_id }} --repository-name=${{ inputs.image_name }} --image-tag "$RELEASE_TAG" --image-manifest file://manifest.json
Comment on lines +188 to +197

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Confirm ECR tag naming rules from AWS documentation

curl -s 'https://docs.aws.amazon.com/AmazonECR/latest/userguide/image-tag-mutability.html' | grep -i "tag" | head -20

Repository: LabAutomationAndScreening/copier-nuxt-python-intranet-app

Length of output: 9713


🌐 Web query:

ECR image tag naming rules format requirements AWS

💡 Result:

AWS ECR image tags (the part after the colon in repository:tag) follow the standard Docker/OCI image tag specification, as ECR is a Docker-compatible registry. Official AWS documentation does not specify additional restrictions beyond length limits in APIs. Key requirements: - Length: 1 to 300 characters [1][2][3]. - Characters: Valid ASCII, lowercase and uppercase letters (a-zA-Z), digits (0-9), underscores (), periods (.), and dashes (-). [4][5][6][7] - Cannot start with a period (.) or dash (-). [4][5] - Regular expression approximation: [a-zA-Z0-9][a-zA-Z0-9._-]{0,127} but ECR allows up to 300 chars total [4][1]. ECR supports pushing images with tags meeting Docker rules using docker tag and docker push [8][7]. Examples in docs use tags like 'latest', 'v1.0', '2017.03' [9][8]. Note: Repository names have stricter patterns (lowercase only, specific separators) up to 256 chars [10], but tags allow uppercase. AWS resource tags (key-value metadata on repositories) are separate: keys up to 128 chars, values up to 256 chars [11].

Citations:


🏁 Script executed:

#!/bin/bash
# Test current and proposed regex patterns
echo "=== Testing current regex: ^[a-zA-Z0-9._-]+$ ==="
CURRENT_REGEX='^[a-zA-Z0-9._-]+$'

test_tags=("valid-tag" "valid_tag" "valid.tag" ".invalid-start" "-invalid-start" "v1.0.0" "UPPER123" "a" "A1" "")

for tag in "${test_tags[@]}"; do
  if [[ "$tag" =~ $CURRENT_REGEX ]]; then
    echo "  ✓ '$tag' matches current regex"
  else
    echo "  ✗ '$tag' does NOT match current regex"
  fi
done

echo ""
echo "=== Testing proposed regex: ^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$ ==="
PROPOSED_REGEX='^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$'

for tag in "${test_tags[@]}"; do
  if [[ "$tag" =~ $PROPOSED_REGEX ]]; then
    echo "  ✓ '$tag' matches proposed regex"
  else
    echo "  ✗ '$tag' does NOT match proposed regex"
  fi
done

echo ""
echo "=== Testing length constraints ==="
# Test 128 char limit
tag_127_chars=$(printf 'a%.0s' {1..127})
tag_128_chars=$(printf 'a%.0s' {1..128})
tag_129_chars=$(printf 'a%.0s' {1..129})

echo "127 chars (valid): ${`#tag_127_chars`} chars"
[[ "$tag_127_chars" =~ $PROPOSED_REGEX ]] && echo "  ✓ matches" || echo "  ✗ does not match"

echo "128 chars (valid): ${`#tag_128_chars`} chars"
[[ "$tag_128_chars" =~ $PROPOSED_REGEX ]] && echo "  ✓ matches" || echo "  ✗ does not match"

echo "129 chars (invalid): ${`#tag_129_chars`} chars"
[[ "$tag_129_chars" =~ $PROPOSED_REGEX ]] && echo "  ✓ matches" || echo "  ✗ does not match"

Repository: LabAutomationAndScreening/copier-nuxt-python-intranet-app

Length of output: 1148


Fix release tag validation to match ECR requirements.

The regex pattern ^[a-zA-Z0-9._-]+$ allows tags starting with . or -, which ECR rejects. Per AWS documentation, ECR tag rules require tags must not start with a period or hyphen, must contain only alphanumeric characters, hyphens, underscores, and dots, and can be 1–300 characters long.

Proposed fix
           RELEASE_TAG="${{ inputs.release-tag }}"
-          if ! [[ "$RELEASE_TAG" =~ ^[a-zA-Z0-9._-]+$ ]]; then
-            echo "Invalid release-tag: '$RELEASE_TAG'. Only alphanumeric characters, hyphens, underscores, and dots are allowed."
+          if ! [[ "$RELEASE_TAG" =~ ^[a-zA-Z0-9][a-zA-Z0-9._-]{0,299}$ ]]; then
+            echo "Invalid release-tag: '$RELEASE_TAG'. Must start with alphanumeric, contain only alphanumeric/hyphens/underscores/dots, and be 1-300 characters."
             exit 1
           fi
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/reusable_workflows/build-docker-image.yaml around lines 188 - 197,
Replace the single regex check on RELEASE_TAG with two POSIX-friendly checks:
first validate allowed characters and length using [[ "$RELEASE_TAG" =~
^[A-Za-z0-9._-]{1,300}$ ]] and then ensure it does not start with a period or
hyphen using [[ ! "$RELEASE_TAG" =~ ^[.-] ]]; update the validation block around
the RELEASE_TAG variable (the test that currently uses ^[a-zA-Z0-9._-]+$) to
fail if either check fails so tags meet ECR rules (1–300 chars, allowed chars
only, and not starting with '.' or '-').


- name: Save Docker Image as tar
if: ${{ inputs.save-as-artifact }}
Expand Down
2 changes: 1 addition & 1 deletion extensions/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ def hook( # noqa: PLR0915 # yes, this is a lot of statements, but it's all just
context["gha_configure_aws_credentials"] = "v6.1.0"
context["gha_amazon_ecr_login"] = "v2.1.5"
context["gha_setup_node"] = "v6.3.0"
context["gha_action_gh_release"] = "v2.2.1"
context["gha_action_gh_release"] = "v3.0.0"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 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
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@extensions/context.py` at line 101, The context key
context["gha_action_gh_release"] currently set to "v3.0.0" may require Node 24;
confirm runner compatibility and either revert to a Node-20-compatible tag
(e.g., "v2.6.2") or make the value conditional based on an environment/feature
flag; update context["gha_action_gh_release"] accordingly and add a short
comment noting the Node runtime requirement so future changes reference the Node
24 breaking change.

context["gha_mutex"] = "1ebad517141198e08d47cf72f3c0975316620a65 # v1.0.0-alpha.10"
context["gha_pypi_publish"] = "v1.14.0"
context["gha_sleep"] = "v2.0.3"
Expand Down
2 changes: 2 additions & 0 deletions template/.github/workflows/ci.yaml.jinja
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,8 @@ jobs:

- name: Install latest versions of packages
uses: ./.github/actions/install_deps
env:
SKIP_PLAYWRIGHT_INSTALL: "1"
with:
node-version: {% endraw %}{{ node_version }}{% raw %}
skip-installing-ssm-plugin-manager: true
Expand Down
46 changes: 46 additions & 0 deletions template/.github/workflows/extract_project_version.py
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()
60 changes: 60 additions & 0 deletions template/.github/workflows/git_tag.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import argparse
import subprocess


def ensure_tag_not_present(tag: str, remote: str) -> None:
no_matching_refs_return_code = 2
result = 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
stdout=subprocess.DEVNULL,
check=False,
)
if result.returncode == 0:
raise Exception(f"Error: tag '{tag}' exists on remote '{remote}'") # noqa: TRY002 # not worth a custom exception
if (
result.returncode != no_matching_refs_return_code
): # anything else is a real error (bad remote, auth failure, network)
raise Exception(f"git ls-remote exited with code {result.returncode} (remote={remote!r})") # noqa: TRY002 # not worth a custom exception


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 or v1.0.6); the tag will always be v<version>",
)
mode = parser.add_mutually_exclusive_group(required=True)
_ = mode.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.",
)
_ = mode.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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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)
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}")
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@template/.github/workflows/git_tag.py` around lines 49 - 57, Add explicit
success logs after the tag creation/push and after the confirm-only flow: when
handling args.push_tag_to_remote (after subprocess.run(["git", "tag", tag"],
...) and after subprocess.run(["git", "push", args.remote, tag"], ...)) emit an
informational message indicating the tag was created and pushed (include tag and
remote); likewise, when args.confirm_tag_not_present triggers
ensure_tag_not_present, emit an informational message confirming the tag is
absent. Use the module logger (e.g., logging.getLogger(__name__).info) or a
clear print statement so CI logs show success for the functions/branches
involving args.push_tag_to_remote, ensure_tag_not_present, and
args.confirm_tag_not_present.


if __name__ == "__main__":
main()
174 changes: 174 additions & 0 deletions template/.github/workflows/release.yaml.jinja
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
{% raw %}name: Release

on:
workflow_dispatch:
inputs:
dry_run:
description: "Dry run (test the workflow, skip actual 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
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: Get CI run ID for this commit
id: get-ci-run-id
env:
GH_TOKEN: ${{ github.token }}
run: |
ALL_RUNS=$(gh api "/repos/${{ github.repository }}/actions/workflows/ci.yaml/runs?head_sha=${{ github.sha }}")
echo "All ci.yaml runs for ${{ github.sha }}:"
echo "$ALL_RUNS" | jq '[.workflow_runs[] | {id, status, conclusion, created_at}]'
LATEST_RUN=$(echo "$ALL_RUNS" | jq '.workflow_runs | first')
RUN_ID=$(echo "$LATEST_RUN" | jq -r '.id')
STATUS=$(echo "$LATEST_RUN" | jq -r '.status')
CONCLUSION=$(echo "$LATEST_RUN" | jq -r '.conclusion')
echo "Most recent ci.yaml run: id=$RUN_ID status=$STATUS conclusion=$CONCLUSION"
if [ "$STATUS" != "completed" ] || [ "$CONCLUSION" != "success" ]; then
echo "Most recent ci.yaml run has not completed successfully. Wait for CI to pass before releasing."
exit 1
fi
echo "ci-run-id=$RUN_ID" >> $GITHUB_OUTPUT
- name: Verify workflow-summary passed in CI run
env:
GH_TOKEN: ${{ github.token }}
run: |
JOBS=$(gh api "/repos/${{ github.repository }}/actions/runs/${{ steps.get-ci-run-id.outputs.ci-run-id }}/jobs")
echo "Jobs in CI run ${{ steps.get-ci-run-id.outputs.ci-run-id }}:"
echo "$JOBS" | jq '[.jobs[] | {name, conclusion}]'
CONCLUSION=$(echo "$JOBS" | jq -r '.jobs[] | select(.name == "workflow-summary") | .conclusion')
echo "workflow-summary conclusion: $CONCLUSION"
if [ "$CONCLUSION" != "success" ] && [ "${{ inputs.dry_run }}" != "true" ]; then
echo "workflow-summary did not succeed. Wait for CI to pass before releasing."
exit 1
fi
Comment on lines +53 to +65

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

# Find the file and examine its content
find . -name "release.yaml.jinja" -type f

Repository: LabAutomationAndScreening/copier-nuxt-python-intranet-app

Length of output: 149


🌐 Web query:

gh api pagination default behavior GitHub CLI documentation

💡 Result:

The default behavior of gh api in GitHub CLI does not automatically paginate results. By default, it fetches only the first page of results from the GitHub API [1]. To fetch all pages, use the --paginate flag, which makes additional HTTP requests following the next link in response headers for REST APIs or using endCursor and pageInfo for GraphQL queries until no more pages exist [1][2]. Each page is output as a separate JSON array or object; combine with --slurp to wrap multiple pages into a single JSON array [1]. Without --paginate, users must manually handle pagination using query parameters like page or per_page [3][4].

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


gh api does not paginate by default — workflow-summary can be silently missed.

gh api without --paginate returns only the first page of results (default 30 items, max per_page=100). The Jobs API (/actions/runs/{id}/jobs) often returns >30 jobs in CI workflows that use OS/Python matrices, and workflow-summary is typically the last job (it depends on all others), so it can fall on a later page. When that happens, the jq filter yields an empty $CONCLUSION and:

  • on a real release this fails the guard with a misleading "workflow-summary did not succeed" message,
  • on a dry run the bypass on line 62 silently lets the workflow continue without ever verifying workflow-summary.

Fetch all pages (or at least bump per_page to 100) so the lookup is reliable:

🔧 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
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@template/.github/workflows/release.yaml.jinja` around lines 53 - 65, The gh
api call that populates JOBS can miss later jobs because it isn't paginated;
update the command that sets JOBS (currently JOBS=$(gh api "/repos/${{
github.repository }}/actions/runs/${{ steps.get-ci-run-id.outputs.ci-run-id
}}/jobs")) to request all pages (e.g. add --paginate) or at minimum include
per_page=100 so the subsequent jq lookup for the workflow-summary job
(CONCLUSION variable and the "Verify workflow-summary passed in CI run" step)
reliably finds the job; keep the rest of the logic (jq filter and dry_run check)
the same.

- 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: 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 %}
Loading