# Nova Publish Pipeline — wheel + Lambda layer + Lambda zip + ECR kj # image, all attached to a GitHub Release per tag (REQ-323, CAP-035, # REQ-354, NFR-6, KJ-STATIC, D-239). # # This workflow is byte-identical across the production forge (GitHub # Actions) and the dev forge (act_runner) — the same file is installed # at .github/workflows/publish.yml and the mirror at # /workflows/publish.yml. Both copies must match exactly # (asserted by tests/test_forge_action_byte_identical.py for the action # and by the repo's byte-identical convention for workflows). # # NFR-6 (wheel/layer co-versioning): every tag publish affecting # core/**, adapters/**, nova/**, or pyproject.toml publishes BOTH a # wheel AND a Lambda layer with identical version strings. If either # publish fails, the job fails and the release is blocked. # # REQ-323: CodeArtifact wheel + Lambda layer pipeline. # REQ-354: per-tag GitHub Release attaching the Lambda token-vend zip, # the Lambda layer zip, the Python wheel, and the ECR kj # container image URI + digest, each with SHA-256 in the body. # CAP-035: Lambda layer ARN version matches the nova-cli wheel version; # the mapping is recorded in SSM /nova/layer/nova-cli/version. # KJ-STATIC: the `kj` Go binary is built CGO_ENABLED=0 and asserted # statically linked by `file(1)` before it is embedded in the # ECR image. The build fails closed if `file kj` does not # contain `statically linked` or does contain `shared library`. # D-239: ECR tags reject `+`; the image tag uses `-` as the separator: # `v1.29.x-kj-`. # # Triggers: # - push of a tag matching `v1.29.*` (the tag carries the version; # REQ-354 criterion 1). Each tag produces an independent release # (criterion 2 — previous tags' artifacts remain downloadable). # - workflow_dispatch (manual republish, e.g. after a CodeArtifact # provisioning fix) # # Wheel index selection (CodeArtifact default + fallback): # - CodeArtifact mode: set the NOVA_CODEARTIFACT_DOMAIN repository # secret (e.g. "nova"). The workflow runs # `aws codeartifact login --tool twine --domain $NOVA_CODEARTIFACT_DOMAIN # --repository nova-pypi` and twine uploads to the CodeArtifact pypi # endpoint. # - Fallback mode: leave NOVA_CODEARTIFACT_DOMAIN unset and provide # TWINE_REPOSITORY_URL + TWINE_USERNAME + TWINE_PASSWORD repository # secrets pointing at any PEP 503 simple index (a private package # registry). twine uploads to TWINE_REPOSITORY_URL. # See docs/codeartifact-provisioning.md for the required IAM grants # + the fallback index shape. # # ECR image (kj substrate, REQ-354 criterion 3): # - The `build-kj-image` job reads platform/abac/kj-version.txt # (line 1 = version tag, line 2 = tree SHA, line 3 = source repo URL). # - It fetches the kj Go source by tag (reliable; the pinned tree SHA # is kept for traceability with v1.28 — see kj-version.txt comments). # - It builds CGO_ENABLED=0, asserts KJ-STATIC via `file(1)`, packages # the binary into public.ecr.aws/lambda/python:3.12-al2023 at # /opt/kj/kj (chmod 0555, sbx_user:1051), and pushes to ECR with tag # v1.29.x-kj-. The tag is validated against # ^[a-zA-Z0-9._-]+$ before push (D-239). # # Secrets / env: # AWS_ROLE_ARN — OIDC role to assume (id-token: write) # NOVA_CODEARTIFACT_DOMAIN — optional; when set, CodeArtifact mode # TWINE_USERNAME — fallback-index upload user # TWINE_PASSWORD — fallback-index upload password # TWINE_REPOSITORY_URL — fallback-index upload URL # AWS_DEFAULT_REGION (optional) — defaults to us-east-1 # NOVA_ECR_REPO — ECR repository URI for the kj image # (e.g. 581513795199.dkr.ecr.us-east-1. # amazonaws.com/nova-kj) name: nova-publish on: push: tags: - "v1.29.*" workflow_dispatch: permissions: id-token: write # OIDC federation to AWS contents: write # create the GitHub Release + upload artifacts jobs: build-kj-image: # KJ substrate — compile the kj Go binary static, package it into a # public.ecr.aws/lambda/python:3.12-al2023 image at /opt/kj/kj, and # push to ECR with tag v1.29.x-kj- (D-239). Records # image_uri + digest for the release body (REQ-354 criterion 4). name: Build + push kj ECR image (KJ-STATIC, D-239) runs-on: ubuntu-latest outputs: image_uri: ${{ steps.ecr-push.outputs.image_uri }} image_digest: ${{ steps.ecr-push.outputs.image_digest }} image_tag: ${{ steps.ecr-push.outputs.image_tag }} steps: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 with: go-version: "1.22" - name: Read kj version pin (platform/abac/kj-version.txt) id: kj-ver run: | set -e KJ_VERSION=$(sed -n '1p' platform/abac/kj-version.txt) KJ_TREE_SHA=$(sed -n '2p' platform/abac/kj-version.txt) KJ_REPO_URL=$(sed -n '3p' platform/abac/kj-version.txt) echo "kj_version=${KJ_VERSION}" >> "$GITHUB_OUTPUT" echo "kj_tree_sha=${KJ_TREE_SHA}" >> "$GITHUB_OUTPUT" echo "kj_repo_url=${KJ_REPO_URL}" >> "$GITHUB_OUTPUT" echo "Pinned kj: version=${KJ_VERSION} tree_sha=${KJ_TREE_SHA} repo=${KJ_REPO_URL}" - name: Fetch kj Go source at tag v0.0.3 env: KJ_REPO_URL: ${{ steps.kj-ver.outputs.kj_repo_url }} KJ_VERSION: ${{ steps.kj-ver.outputs.kj_version }} run: | set -e # The pinned tree SHA (line 2) 404s as a commit; the build # fetches by tag, which dereferences to a real commit # (verified: 924a6af2474523c4e27e3a826248c91c8fe1d1cf). rm -rf kj-src git clone --depth 1 --branch "${KJ_VERSION}" \ "${KJ_REPO_URL}" kj-src - name: Build kj (CGO_ENABLED=0 — KJ-STATIC) working-directory: kj-src run: | set -e # Resolve the tagged commit SHA — this is the source SHA # embedded in the ECR image tag (REQ-354 criterion 3). KJ_SOURCE_SHA=$(git rev-parse HEAD) echo "kj_source_sha=${KJ_SOURCE_SHA}" >> "$GITHUB_ENV" CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \ go build -ldflags="-s -w" -o kj ./... file kj - name: Assert kj is statically linked (KJ-STATIC CI gate) working-directory: kj-src run: | set -e # KJ-STATIC: file(1) MUST report `statically linked` and MUST # NOT report `shared library`. Fail closed otherwise — this # is the mechanical enforcement of KJ-STATIC (not human review). FILE_OUT=$(file kj) echo "$FILE_OUT" case "$FILE_OUT" in *statically\ linked*) ;; *) echo "FAIL (KJ-STATIC): kj is not statically linked"; exit 1 ;; esac case "$FILE_OUT" in *shared\ library*) echo "FAIL (KJ-STATIC): kj links a shared library"; exit 1 ;; *) ;; esac # readelf defense-in-depth: assert no NEEDED entries. if readelf -d kj 2>/dev/null | grep -q NEEDED; then echo "FAIL (KJ-STATIC): readelf -d reports NEEDED entries"; exit 1 fi echo "KJ-STATIC assertion passed." - name: Configure AWS credentials (OIDC) uses: aws-actions/configure-aws-credentials@v4 with: role-to-assume: ${{ secrets.AWS_ROLE_ARN }} aws-region: ${{ secrets.AWS_DEFAULT_REGION || 'us-east-1' }} - name: Log in to ECR env: NOVA_ECR_REPO: ${{ secrets.NOVA_ECR_REPO }} run: | set -e # NOVA_ECR_REPO is the full repo URI, e.g. # 581513795199.dkr.ecr.us-east-1.amazonaws.com/nova-kj REGISTRY=$(echo "$NOVA_ECR_REPO" | cut -d/ -f1) aws ecr get-login-password --region "${AWS_REGION}" \ | docker login --username AWS --password-stdin "$REGISTRY" - name: Build + push kj image to ECR (D-239) id: ecr-push env: NOVA_ECR_REPO: ${{ secrets.NOVA_ECR_REPO }} KJ_SOURCE_SHA: ${{ env.kj_source_sha }} working-directory: kj-src run: | set -e # D-239: ECR tags reject `+`; use `-` separator. The tag is # v1.29.x-kj- and is validated against # ^[a-zA-Z0-9._-]+$ before push. IMAGE_TAG="v1.29.x-kj-${KJ_SOURCE_SHA}" if ! echo "$IMAGE_TAG" | grep -Eq '^[a-zA-Z0-9._-]+$'; then echo "FAIL (D-239): invalid ECR tag: ${IMAGE_TAG}" exit 1 fi IMAGE_URI="${NOVA_ECR_REPO}:${IMAGE_TAG}" echo "Pushing image: ${IMAGE_URI}" # Stage the binary into a build context root. rm -rf imgctx && mkdir -p imgctx/opt/kj cp kj imgctx/opt/kj/kj chmod 0555 imgctx/opt/kj/kj printf '%s\n' \ 'FROM public.ecr.aws/lambda/python:3.12-al2023' \ 'COPY --chown=sbx_user:1051 --chmod=0555 opt/kj/kj /opt/kj/kj' \ > imgctx/Dockerfile docker build -t "$IMAGE_URI" imgctx docker push "$IMAGE_URI" >/tmp/docker-push.log 2>&1 cat /tmp/docker-push.log # Extract the registry digest via `docker inspect` (the # canonical source — push output wording varies by client). IMAGE_DIGEST=$(docker inspect --format='{{index .RepoDigests 0}}' \ "$IMAGE_URI" | sed 's/.*@//') echo "image_uri=${IMAGE_URI}" >> "$GITHUB_OUTPUT" echo "image_digest=${IMAGE_DIGEST}" >> "$GITHUB_OUTPUT" echo "image_tag=${IMAGE_TAG}" >> "$GITHUB_OUTPUT" echo "Pushed ${IMAGE_URI} @ ${IMAGE_DIGEST}" publish: name: Publish wheel + Lambda layer + Lambda zip + Release runs-on: ubuntu-latest needs: build-kj-image steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: python-version: "3.12" - name: Configure AWS credentials (OIDC) uses: aws-actions/configure-aws-credentials@v4 with: role-to-assume: ${{ secrets.AWS_ROLE_ARN }} aws-region: ${{ secrets.AWS_DEFAULT_REGION || 'us-east-1' }} - name: Install build + publish tools run: pip install build twine - name: Compute version from pyproject.toml id: ver run: | set -e VERSION=$(python -c 'import tomllib;print(tomllib.load(open("pyproject.toml","rb"))["project"]["version"])') echo "version=$VERSION" >> "$GITHUB_OUTPUT" echo "Nova version: $VERSION" - name: Build wheel run: | set -e python -m build --wheel ls -1 dist/ - name: Upload wheel to index (CodeArtifact default + fallback) id: wheel env: NOVA_CODEARTIFACT_DOMAIN: ${{ secrets.NOVA_CODEARTIFACT_DOMAIN }} TWINE_USERNAME: ${{ secrets.TWINE_USERNAME }} TWINE_PASSWORD: ${{ secrets.TWINE_PASSWORD }} TWINE_REPOSITORY_URL: ${{ secrets.TWINE_REPOSITORY_URL }} run: | set -e # CodeArtifact mode: log in to the domain's pypi repository. if [ -n "$NOVA_CODEARTIFACT_DOMAIN" ]; then echo "CodeArtifact mode: domain=$NOVA_CODEARTIFACT_DOMAIN repository=nova-pypi" aws codeartifact login --tool twine \ --domain "$NOVA_CODEARTIFACT_DOMAIN" --repository nova-pypi else echo "Fallback-index mode: uploading to TWINE_REPOSITORY_URL" if [ -z "$TWINE_REPOSITORY_URL" ] || [ -z "$TWINE_USERNAME" ] || [ -z "$TWINE_PASSWORD" ]; then echo "FAIL: NOVA_CODEARTIFACT_DOMAIN is unset and one of TWINE_REPOSITORY_URL/TWINE_USERNAME/TWINE_PASSWORD is missing." exit 1 fi fi # Idempotent upload: a re-run for the same version may hit # "file already exists" on the index. Treat that as success. # Capture both attempts' output so a genuine failure (auth, # network, invalid package) is NOT masked as success — NFR-6 # requires the job to fail if the wheel publish fails. if twine upload "dist/nova-${{ steps.ver.outputs.version }}-*.whl" 2>&1 | tee /tmp/twine.log; then echo "uploaded=true" >> "$GITHUB_OUTPUT" else # Retry once; the first attempt may have failed with a # transient error OR with "already exists" (a re-run). twine upload "dist/nova-${{ steps.ver.outputs.version }}-*.whl" 2>&1 | tee -a /tmp/twine.log || true if grep -qi "already exist" /tmp/twine.log 2>/dev/null; then echo "Wheel already present on the index — treating as success (idempotent)." echo "uploaded=true" >> "$GITHUB_OUTPUT" else echo "FAIL: wheel upload failed (not an idempotent re-run)." >&2 cat /tmp/twine.log >&2 exit 1 fi fi - name: Build Lambda layer run: | set -e rm -rf layer mkdir -p layer/python # Install the wheel we just built + the identity extras' deps # so the layer carries argon2-cffi, cryptography, pyjwt. pip install --target layer/python/ \ "dist/nova-${{ steps.ver.outputs.version }}-*.whl" \ argon2-cffi cryptography pyjwt ( cd layer && zip -r ../nova-cli-layer-v1.29.x.zip python/ ) ls -lh nova-cli-layer-v1.29.x.zip - name: Publish Lambda layer id: layer run: | set -e ARN=$(aws lambda publish-layer-version \ --layer-name nova-cli \ --zip-file fileb://nova-cli-layer-v1.29.x.zip \ --compatible-runtimes python3.12 \ --compatible-architectures x86_64 \ --description "nova-cli v${{ steps.ver.outputs.version }}" \ --query LayerVersionArn --output text) echo "arn=$ARN" >> "$GITHUB_OUTPUT" echo "Published Lambda layer: $ARN" - name: Record SSM version↔ARN mapping (CAP-035) run: | set -e aws ssm put-parameter \ --name /nova/layer/nova-cli/version \ --value "${{ steps.ver.outputs.version }}:${{ steps.layer.outputs.arn }}" \ --type String --overwrite echo "SSM /nova/layer/nova-cli/version = ${{ steps.ver.outputs.version }}:${{ steps.layer.outputs.arn }}" - name: Build Lambda token-vend zip (nova-lambda-token-vend-v1.29.x.zip) run: | set -e # Package the nova-idp-token-vend Lambda handler (the dual-use # module core/lambda/nova_idp_token_vend.py) plus the core/ # package modules it imports at runtime (core.policy_engine, # core.abac_evaluator, core.kms_signing). The zip root mirrors # the repo layout so `import core.lambda.nova_idp_token_vend` # resolves inside the Lambda execution environment. rm -rf lambdazip mkdir -p lambdazip/core/lambda cp core/lambda/__init__.py lambdazip/core/lambda/__init__.py cp core/lambda/nova_idp_token_vend.py \ lambdazip/core/lambda/nova_idp_token_vend.py # Carry the core/ modules the handler imports lazily. cp core/__init__.py lambdazip/core/__init__.py 2>/dev/null || true cp core/policy_engine.py lambdazip/core/policy_engine.py 2>/dev/null || true cp core/abac_evaluator.py lambdazip/core/abac_evaluator.py 2>/dev/null || true cp core/kms_signing.py lambdazip/core/kms_signing.py 2>/dev/null || true ( cd lambdazip && zip -r ../nova-lambda-token-vend-v1.29.x.zip . ) ls -lh nova-lambda-token-vend-v1.29.x.zip - name: Compute SHA-256 of all release artifacts id: sha run: | set -e sha256sum nova-lambda-token-vend-v1.29.x.zip \ > /tmp/sha-lambda.txt sha256sum nova-cli-layer-v1.29.x.zip \ > /tmp/sha-layer.txt sha256sum dist/nova-${{ steps.ver.outputs.version }}-*.whl \ > /tmp/sha-wheel.txt { echo "## Artifact SHA-256 (REQ-354)" echo "" echo "### nova-lambda-token-vend-v1.29.x.zip" echo '```' cat /tmp/sha-lambda.txt echo '```' echo "" echo "### nova-cli-layer-v1.29.x.zip" echo '```' cat /tmp/sha-layer.txt echo '```' echo "" echo "### nova-${{ steps.ver.outputs.version }}-py3-none-any.whl" echo '```' cat /tmp/sha-wheel.txt echo '```' echo "" echo "### ECR kj image (REQ-354 criterion 3/4)" echo "- URI: \`${{ needs.build-kj-image.outputs.image_uri }}\`" echo "- digest: \`${{ needs.build-kj-image.outputs.image_digest }}\`" echo "- tag: \`${{ needs.build-kj-image.outputs.image_tag }}\`" echo "" } > /tmp/release-body.md echo "body_path=/tmp/release-body.md" >> "$GITHUB_OUTPUT" echo "--- Release body ---" cat /tmp/release-body.md - name: Create GitHub Release + attach artifacts (REQ-354) uses: softprops/action-gh-release@v2 with: # Use the pushed tag as the release tag. tag_name: ${{ github.ref_name }} name: Nova ${{ github.ref_name }} body_path: ${{ steps.sha.outputs.body_path }} files: | nova-lambda-token-vend-v1.29.x.zip nova-cli-layer-v1.29.x.zip dist/nova-${{ steps.ver.outputs.version }}-*.whl env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Fail job if either publish failed (REQ-323 AC) if: ${{ steps.wheel.outputs.uploaded != 'true' || steps.layer.outputs.arn == '' }} run: | echo "FAIL: wheel uploaded=${{ steps.wheel.outputs.uploaded }} layer_arn=${{ steps.layer.outputs.arn }}" exit 1