# Nova Publish Pipeline — wheel + Lambda layer (REQ-323, CAP-035, NFR-6) # # 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 merge to main 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 merge is blocked. # # REQ-323: CodeArtifact wheel + Lambda layer pipeline. # CAP-035: Lambda layer ARN version matches the nova-cli wheel version; # the mapping is recorded in SSM /nova/layer/nova-cli/version. # # Triggers: # - push to main when core/**, adapters/**, nova/**, or pyproject.toml # changed (the surfaces that ship in the wheel + layer) # - 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. # # 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 name: nova-publish on: push: branches: [main] paths: - "core/**" - "adapters/**" - "nova/**" - "pyproject.toml" workflow_dispatch: permissions: id-token: write # OIDC federation to AWS contents: write # tag the release jobs: publish: name: Publish wheel + Lambda layer runs-on: ubuntu-latest 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. twine upload "dist/nova-${{ steps.ver.outputs.version }}-*.whl" \ || twine upload "dist/nova-${{ steps.ver.outputs.version }}-*.whl" 2>&1 | tee /tmp/twine.log if grep -qi "already exist" /tmp/twine.log 2>/dev/null; then echo "Wheel already present on the index — treating as success (idempotent)." fi echo "uploaded=true" >> "$GITHUB_OUTPUT" - 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-layer.zip python/ ) ls -lh nova-layer.zip - name: Publish Lambda layer id: layer run: | set -e ARN=$(aws lambda publish-layer-version \ --layer-name nova-cli \ --zip-file fileb://nova-layer.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: 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