Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c19cc68d15 |
+10
-10
@@ -1,25 +1,25 @@
|
|||||||
{
|
{
|
||||||
"phase": 1,
|
"phase": 2,
|
||||||
"stage": "verify",
|
"stage": "verify",
|
||||||
"milestone": "v1.29",
|
"milestone": "v1.29",
|
||||||
"phase_role": "execution",
|
"phase_role": "execution",
|
||||||
"attempts": 0,
|
"attempts": 0,
|
||||||
"updated_at": "2026-08-20T01:00:00Z",
|
"updated_at": "2026-08-20T01:10:00Z",
|
||||||
"project": "acdl",
|
"project": "acdl",
|
||||||
"projects": ["acdl", "nova-blockchain-exchange"],
|
"projects": ["acdl", "nova-blockchain-exchange"],
|
||||||
"active_milestone": "v1.29",
|
"active_milestone": "v1.29",
|
||||||
"milestone_branch": "milestone/v1.29-reposplit-identity",
|
"milestone_branch": "milestone/v1.29-reposplit-identity",
|
||||||
"phase_branch": "phase/01-publish-pipeline",
|
"phase_branch": "phase/02-gitea-scrub-decisions",
|
||||||
"tag_line": "v1.28.x",
|
"tag_line": "v1.28.x",
|
||||||
"phase_name": "publish-pipeline",
|
"phase_name": "gitea-scrub-decisions",
|
||||||
"milestone_type": "feature",
|
"milestone_type": "feature",
|
||||||
"reqs_covered": ["REQ-354"],
|
"reqs_covered": ["REQ-354", "REQ-367", "REQ-368"],
|
||||||
"reqs_partial": [],
|
"reqs_partial": [],
|
||||||
"verification": {
|
"verification": {
|
||||||
"structural": "PASS (py_compile exit 0, YAML structure valid)",
|
"structural": "PASS (py_compile exit 0 on 5 test files, bash -n OK on scripts)",
|
||||||
"behavioral": "PASS (17 test functions AST-discoverable; pytest not installed in sandbox — CI venv will run)",
|
"behavioral": "PASS (grep -rni gitea .github/ docs/ pyproject.toml README.md scripts/ -> zero matches; .gitea/ absent; forge_parity_disabled CI job added)",
|
||||||
"security": "PASS (KJ-STATIC CI gate wired, ABAC fail-closed test authored, M-001 documented + mitigated)",
|
"security": "PASS (D-232 forge parity abandoned, CI asserts forge_parity_disabled)",
|
||||||
"quality": "PASS (test_abac_e2e.py covers Edge 5 item 7, test_kms_roundtrip.py live_aws marker added)"
|
"quality": "PASS (forge parity tests updated to assert disabled state, D-232 documented)"
|
||||||
},
|
},
|
||||||
"notes": "v1.29 P1 EXECUTE+VERIFY complete. publish.yml rewritten: tag-triggered (v1.29.*), build-kj-image job (CGO_ENABLED=0, KJ-STATIC file(1) gate, ECR tag v1.29.x-kj-<sha> D-239), Lambda zip + layer + wheel + image attached to GitHub Release with SHA-256. kj-version.txt updated with repo URL (CF-4). test_abac_e2e.py authored (5 tests, ABAC allowed/denied/fail-closed). test_kms_roundtrip.py live_aws marker added. NOTE for P2: test_forge_action_byte_identical.py + test_no_forge_mentions.py + test_synced_copies_match will break after Gitea scrub — must update/remove in P2."
|
"notes": "v1.29 P2 EXECUTE+VERIFY complete. .gitea/ removed (7 files), scripts/sync_workflows.py + ship_phase.sh + attach_release_asset.py removed, scripts/rotate_spike_key.sh + sync_to_nova.sh scrubbed, pyproject.toml -> 1.29.0, forge_parity_disabled CI job added, 5 forge parity tests updated. D-232..240 verified present in PROJECT.md/CLARIFY/REQUIREMENTS."
|
||||||
}
|
}
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
# Gitea Workflows — Limitation Documentation (v1.14, REQ-150)
|
|
||||||
|
|
||||||
## Shared workflows (byte-identical Gitea + GitHub)
|
|
||||||
|
|
||||||
These 3 workflows exist in both `.gitea/workflows/` and `.github/workflows/`
|
|
||||||
and are byte-identical (asserted by `tests/test_pipeline_contract.py`):
|
|
||||||
|
|
||||||
- `ci.yml` — lint + test + check-only (runs on every PR)
|
|
||||||
- `deploy.yml` — reusable deploy workflow (invoked by consumer repos)
|
|
||||||
- `modules-lifecycle.yml` — L1 + L2 module lifecycle pipeline (plan-only
|
|
||||||
default, full on workflow_dispatch override)
|
|
||||||
|
|
||||||
## GitHub-only workflows (no Gitea mirror)
|
|
||||||
|
|
||||||
These 4 workflows exist only in `.github/workflows/`:
|
|
||||||
|
|
||||||
- `platform-test.yml` — PR pipeline: lint + unit + integration + schema
|
|
||||||
validation. Uses GitHub Actions features (reusable workflow composition,
|
|
||||||
environment protection) not available in Gitea Actions.
|
|
||||||
- `primitives-plan.yml` — PR plan-only matrix over all L1 primitives. Uses
|
|
||||||
GitHub matrix strategy + `terraform plan` against live AWS.
|
|
||||||
- `patterns-plan.yml` — PR plan-only matrix over all L2 modules. Same
|
|
||||||
pattern as primitives-plan.
|
|
||||||
- `release.yml` — release job on merge to main: computes next semver,
|
|
||||||
creates + updates MAJOR.MINOR.PATCH / MAJOR.MINOR / MAJOR floating tags,
|
|
||||||
creates a GitHub release. GitHub-only by design (Gitea releases are
|
|
||||||
created via the ship workflow's API call, not a workflow).
|
|
||||||
|
|
||||||
## Why no Gitea mirror
|
|
||||||
|
|
||||||
Gitea Actions (act_runner) has limited support for reusable workflow
|
|
||||||
composition, environment protection, and the `gh` CLI used by the release
|
|
||||||
job. The 3 shared workflows are the ones that need to run on both forges
|
|
||||||
(CI + deploy + lifecycle). The 4 GitHub-only workflows are the
|
|
||||||
production-grade platform pipelines that run on GitHub Actions; Gitea is
|
|
||||||
the dev/integration forge. Mirroring them would require feature parity
|
|
||||||
that Gitea Actions does not currently provide.
|
|
||||||
|
|
||||||
This is a documented limitation, not a defect. A future milestone may
|
|
||||||
add Gitea mirrors if act_runner gains the required features.
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
# Nova CI Pipeline (dev environment)
|
|
||||||
#
|
|
||||||
# This workflow implements the central pipeline contract:
|
|
||||||
# pipelines/ci.yml (validated against schemas/pipeline.schema.json)
|
|
||||||
#
|
|
||||||
# The same contract is implemented by .github/workflows/ci.yml (GitHub
|
|
||||||
# Actions, production). Both files must be byte-identical — the only
|
|
||||||
# declared difference is the forge/runtime, not the stages or commands.
|
|
||||||
#
|
|
||||||
# Shell reproducibility: scripts/run_ci.sh runs the same 3 stages locally.
|
|
||||||
#
|
|
||||||
# Stages (from the contract):
|
|
||||||
# 1. lint — py_compile all Python files
|
|
||||||
# 2. test — pytest test suite (offline, no AWS)
|
|
||||||
# 3. check-only — run_platform.sh --check-only (offline, no AWS)
|
|
||||||
name: acdl-ci
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [main]
|
|
||||||
pull_request:
|
|
||||||
branches: [main]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
lint:
|
|
||||||
name: Lint
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- uses: actions/setup-python@v5
|
|
||||||
with:
|
|
||||||
python-version: "3.12"
|
|
||||||
|
|
||||||
- name: Compile all Python files
|
|
||||||
run: |
|
|
||||||
python3 -m py_compile \
|
|
||||||
core/confidence_signal.py \
|
|
||||||
core/outbox_writer.py \
|
|
||||||
core/output_publisher.py \
|
|
||||||
core/contract_resolver.py \
|
|
||||||
core/lambda/contract_ingestor.py \
|
|
||||||
adapters/terraform/adapter.py \
|
|
||||||
adapters/terraform/policy/checkov_adapter.py \
|
|
||||||
scripts/push_consumer_image.py
|
|
||||||
|
|
||||||
test:
|
|
||||||
name: Test
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- uses: actions/setup-python@v5
|
|
||||||
with:
|
|
||||||
python-version: "3.12"
|
|
||||||
|
|
||||||
- name: Install Terraform 1.9.*
|
|
||||||
run: |
|
|
||||||
wget -qO- https://apt.releases.hashicorp.com/gpg | sudo gpg --dearmor -o /usr/share/keyrings/hashicorp.gpg
|
|
||||||
echo "deb [signed-by=/usr/share/keyrings/hashicorp.gpg] https://apt.releases.hashicorp.com $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/hashicorp.list
|
|
||||||
sudo apt-get update && sudo apt-get install -y terraform=1.9.*
|
|
||||||
|
|
||||||
- name: Install test dependencies
|
|
||||||
run: pip install -r requirements-test.txt
|
|
||||||
|
|
||||||
- name: Run pytest
|
|
||||||
run: python3 -m pytest tests/ -v --tb=short
|
|
||||||
|
|
||||||
check-only:
|
|
||||||
name: Platform check-only (offline)
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- uses: actions/setup-python@v5
|
|
||||||
with:
|
|
||||||
python-version: "3.12"
|
|
||||||
|
|
||||||
- name: Install Terraform 1.9.*
|
|
||||||
run: |
|
|
||||||
wget -qO- https://apt.releases.hashicorp.com/gpg | sudo gpg --dearmor -o /usr/share/keyrings/hashicorp.gpg
|
|
||||||
echo "deb [signed-by=/usr/share/keyrings/hashicorp.gpg] https://apt.releases.hashicorp.com $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/hashicorp.list
|
|
||||||
sudo apt-get update && sudo apt-get install -y terraform=1.9.*
|
|
||||||
|
|
||||||
- name: Install runtime dependencies
|
|
||||||
run: pip install jsonschema pyyaml boto3
|
|
||||||
|
|
||||||
- name: Run platform check-only
|
|
||||||
run: bash scripts/run_platform.sh --check-only
|
|
||||||
@@ -1,168 +0,0 @@
|
|||||||
# Nova Reusable Deploy Workflow (dev environment)
|
|
||||||
#
|
|
||||||
# This reusable workflow implements the central deployment pipeline contract:
|
|
||||||
# pipelines/contract.yml (validated against schemas/deploy-pipeline.schema.json)
|
|
||||||
#
|
|
||||||
# The same contract is implemented by .github/workflows/deploy.yml (GitHub
|
|
||||||
# Actions, production). Both files must be byte-identical — the only
|
|
||||||
# declared difference is the forge/runtime, not the stages or commands.
|
|
||||||
#
|
|
||||||
# Consumer repos invoke this workflow via a versioned tag (floating MAJOR + MINOR):
|
|
||||||
# uses: nova/.github/workflows/deploy.yml@v1.19
|
|
||||||
# uses: acdl/.github/workflows/deploy.yml@v1.9 (GitHub)
|
|
||||||
#
|
|
||||||
# Unversioned references (@main, bare) are discouraged — the consumer's setup
|
|
||||||
# must be immutable + resilient. The versioned tag is the only immutability
|
|
||||||
# lever (version constraints cannot be expressed inside the contract).
|
|
||||||
#
|
|
||||||
# What this workflow does:
|
|
||||||
# 1. Checks out the consumer repo (the repo that invoked the workflow).
|
|
||||||
# 2. Checks out the ACDL platform repo into the workspace (platform/).
|
|
||||||
# This is the run-time fetch — consumers never clone the platform repo.
|
|
||||||
# 3. Installs runtime deps: Python 3.12, Terraform 1.9.*, Checkov.
|
|
||||||
# 4. Configures AWS auth (OIDC default; static-key override via secrets).
|
|
||||||
# 5. Runs scripts/run_platform.sh against the consumer's contract path.
|
|
||||||
# 6. Uploads artifacts (emitted Terraform, Checkov JSON, confidence JSON,
|
|
||||||
# platform log) for auditability.
|
|
||||||
#
|
|
||||||
# Inputs:
|
|
||||||
# contract — path to the consumer's contract YAML (default .nova/contract.yml)
|
|
||||||
# mode — full | plan-only | check-only (default full; dev = full apply,
|
|
||||||
# higher environments hold for HITL — the calling repo or the
|
|
||||||
# forge environment gate enforces that)
|
|
||||||
#
|
|
||||||
# Auth (zero-trust default — see README.md#credentials--zero-trust):
|
|
||||||
# OIDC federation is the default. permissions: id-token: write lets the
|
|
||||||
# forge mint a short-lived STS token. The role-to-assume is scoped by the
|
|
||||||
# consumer's repository identity (ABAC) — the workflow assumes the role
|
|
||||||
# that matches repo:org/consumer-repo:ref:refs/heads/main, and the session
|
|
||||||
# policy restricts view/update to resources tagged acdl:owner=<consumer-repo>.
|
|
||||||
#
|
|
||||||
# Override (where OIDC is unavailable, e.g. pending
|
|
||||||
# upstream forge OIDC support): set NOVA_AWS_ACCESS_KEY_ID + NOVA_AWS_SECRET_ACCESS_KEY
|
|
||||||
# as repository secrets. The platform-managed scheduled pipeline rotates
|
|
||||||
# the key on a daily cadence. When .env.secrets is used locally instead,
|
|
||||||
# rotating the key out of band is the consumer's responsibility.
|
|
||||||
name: nova-deploy
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_call:
|
|
||||||
inputs:
|
|
||||||
contract:
|
|
||||||
description: Path to the consumer contract YAML (in the consumer repo)
|
|
||||||
type: string
|
|
||||||
default: .nova/contract.yml
|
|
||||||
mode:
|
|
||||||
description: Pipeline mode — full (apply), plan-only, check-only, or decommission
|
|
||||||
type: string
|
|
||||||
default: full
|
|
||||||
changeRequestId:
|
|
||||||
description: Change request ID (required for decommission mode — validated against CMDB)
|
|
||||||
type: string
|
|
||||||
default: ""
|
|
||||||
environment:
|
|
||||||
description: Target environment override (dev/qa/prod/dr); when empty, the contract's environment field is used
|
|
||||||
type: string
|
|
||||||
default: ""
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
id-token: write
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
deploy:
|
|
||||||
name: Deploy
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Check out consumer repo
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Check out ACDL platform repo
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
repository: acdl/acdl
|
|
||||||
path: platform
|
|
||||||
ref: v1.25
|
|
||||||
|
|
||||||
- uses: actions/setup-python@v5
|
|
||||||
with:
|
|
||||||
python-version: "3.12"
|
|
||||||
|
|
||||||
- name: Install runtime dependencies
|
|
||||||
run: |
|
|
||||||
pip install --break-system-packages jsonschema pyyaml boto3
|
|
||||||
pip install --break-system-packages "checkov>=3.2,<4"
|
|
||||||
|
|
||||||
- name: Install Terraform 1.9.*
|
|
||||||
run: |
|
|
||||||
wget -qO- https://apt.releases.hashicorp.com/gpg | sudo gpg --dearmor -o /usr/share/keyrings/hashicorp.gpg
|
|
||||||
echo "deb [signed-by=/usr/share/keyrings/hashicorp.gpg] https://apt.releases.hashicorp.com $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/hashicorp.list
|
|
||||||
sudo apt-get update && sudo apt-get install -y terraform=1.9.*
|
|
||||||
|
|
||||||
- name: Configure AWS credentials (OIDC default + static-key override)
|
|
||||||
uses: aws-actions/configure-aws-credentials@v4
|
|
||||||
with:
|
|
||||||
# P4 (REQ-163): IAM role renamed acdl-deploy- → nova-deploy-.
|
|
||||||
role-to-assume: ${{ secrets.NOVA_AWS_ACCESS_KEY_ID == '' && format('arn:aws:iam::{0}:role/nova-deploy-{1}', secrets.NOVA_AWS_ACCOUNT_ID, github.repository_id) || '' }}
|
|
||||||
aws-region: ${{ secrets.AWS_DEFAULT_REGION || 'us-east-1' }}
|
|
||||||
access-key-id: ${{ secrets.NOVA_AWS_ACCESS_KEY_ID }}
|
|
||||||
secret-access-key: ${{ secrets.NOVA_AWS_SECRET_ACCESS_KEY }}
|
|
||||||
|
|
||||||
- name: Run the platform pipeline
|
|
||||||
working-directory: ${{ github.workspace }}
|
|
||||||
env:
|
|
||||||
NOVA_CONSUMER_REPO: ${{ github.repository }}
|
|
||||||
run: |
|
|
||||||
MODE_FLAG=""
|
|
||||||
case "${{ inputs.mode }}" in
|
|
||||||
full) MODE_FLAG="" ;;
|
|
||||||
plan-only) MODE_FLAG="--plan-only" ;;
|
|
||||||
check-only) MODE_FLAG="--check-only" ;;
|
|
||||||
decommission)
|
|
||||||
if [ -z "${{ inputs.changeRequestId }}" ]; then
|
|
||||||
echo "FAIL: changeRequestId is required for decommission mode"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
MODE_FLAG="--decommission ${{ inputs.changeRequestId }}"
|
|
||||||
;;
|
|
||||||
*) echo "Unknown mode: ${{ inputs.mode }}"; exit 1 ;;
|
|
||||||
esac
|
|
||||||
ENV_FLAG=""
|
|
||||||
if [ -n "${{ inputs.environment }}" ]; then
|
|
||||||
ENV_FLAG="--environment ${{ inputs.environment }}"
|
|
||||||
fi
|
|
||||||
bash platform/scripts/run_platform.sh $MODE_FLAG $ENV_FLAG "${{ inputs.contract }}"
|
|
||||||
|
|
||||||
- name: Post stage summary comment to PR
|
|
||||||
if: success() && github.event_name == 'pull_request'
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
|
||||||
GITHUB_REF: ${{ github.ref }}
|
|
||||||
run: |
|
|
||||||
bash platform/scripts/post_stage_comment.sh deploy pass '{"mode":"${{ inputs.mode }}","runId":"${{ github.run_id }}"}'
|
|
||||||
|
|
||||||
- name: Report error to platform team (on failure)
|
|
||||||
if: failure()
|
|
||||||
env:
|
|
||||||
AWS_DEFAULT_REGION: us-east-1
|
|
||||||
run: |
|
|
||||||
aws lambda invoke-function-url \
|
|
||||||
--function-url "${{ secrets.NOVA_LAMBDA_URL }}" \
|
|
||||||
--cli-binary-format raw-in-base64-out \
|
|
||||||
--payload "$(python3 -c "import json,os; print(json.dumps({'action':'report_error','consumerRepo':os.environ.get('GITHUB_REPOSITORY',''),'contractId':'${{ github.run_id }}','error':'Deploy pipeline failed. See run logs.','runUrl':'${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}','environment':'dev'}))")" \
|
|
||||||
/dev/null || true
|
|
||||||
|
|
||||||
- name: Upload emitted Terraform
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: nova-terraform
|
|
||||||
path: /tmp/nova_platform_run/tf/*.tf
|
|
||||||
if-no-files-found: warn
|
|
||||||
|
|
||||||
- name: Upload platform log
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: nova-platform-log
|
|
||||||
path: platform/logs/
|
|
||||||
if-no-files-found: warn
|
|
||||||
@@ -1,207 +0,0 @@
|
|||||||
# Nova Modules Lifecycle Pipeline (dev environment)
|
|
||||||
#
|
|
||||||
# Matrix-runs each L1 module's examples/{simple,complex}.yml contracts through
|
|
||||||
# apply→modify→destroy against live AWS. No per-module Python. The "test" =
|
|
||||||
# the pipeline cell going green.
|
|
||||||
#
|
|
||||||
# Also matrix-runs L2 composition modules (static-assets, microservice) through
|
|
||||||
# the same apply→modify→destroy lifecycle. L2 = composition only (no L2
|
|
||||||
# terraform files); the composition must be deterministic.
|
|
||||||
#
|
|
||||||
# This workflow implements pipelines/modules-lifecycle.yml (byte-identical
|
|
||||||
# in .github/workflows/).
|
|
||||||
#
|
|
||||||
# Lifecycle mode (REQ-134, v1.12): the `lifecycle_mode` input defaults to
|
|
||||||
# "plan" — the lifecycle scripts run `run_platform.sh --plan-only` (fast,
|
|
||||||
# no AWS mutation, validates the contract->resolver->adapter->plan chain
|
|
||||||
# for every module on every PR, with no AWS credentials or cost). Set to
|
|
||||||
# "full" via workflow_dispatch (or the NOVA_LIFECYCLE_MODE repo variable)
|
|
||||||
# to run the real apply→modify→destroy against live AWS. In plan mode the
|
|
||||||
# short-lived CI VPC apply/destroy jobs are skipped (nothing is applied).
|
|
||||||
#
|
|
||||||
# A short-lived CI VPC (terraform/ci-vpc/) is created before testing VPC-dependent
|
|
||||||
# modules (alb, ecs-service, rds, uptime, and L2 microservice) and destroyed
|
|
||||||
# after all tests complete. The CI VPC is separate from the long-lived platform
|
|
||||||
# VPC. Outputs are read from the S3 state by each lifecycle job (no artifact
|
|
||||||
# passing needed).
|
|
||||||
name: acdl-modules-lifecycle
|
|
||||||
|
|
||||||
on:
|
|
||||||
pull_request:
|
|
||||||
branches: [main]
|
|
||||||
workflow_dispatch:
|
|
||||||
inputs:
|
|
||||||
lifecycle_mode:
|
|
||||||
description: "Lifecycle mode: 'plan' (default, fast, no AWS mutation) or 'full' (real apply→modify→destroy against live AWS)"
|
|
||||||
required: false
|
|
||||||
default: "plan"
|
|
||||||
type: choice
|
|
||||||
options:
|
|
||||||
- plan
|
|
||||||
- full
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
# Prerequisite: apply the short-lived CI VPC (needed by VPC-dependent L1s + L2 microservice)
|
|
||||||
# Skipped in plan mode (no resources are applied, so no VPC is needed).
|
|
||||||
ci-vpc-apply:
|
|
||||||
name: CI VPC apply
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
if: ${{ github.event.inputs.lifecycle_mode != 'plan' && vars.NOVA_LIFECYCLE_MODE != 'plan' }}
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- name: Install Terraform 1.9.*
|
|
||||||
run: |
|
|
||||||
wget -qO- https://apt.releases.hashicorp.com/gpg | sudo gpg --dearmor -o /usr/share/keyrings/hashicorp.gpg
|
|
||||||
echo "deb [signed-by=/usr/share/keyrings/hashicorp.gpg] https://apt.releases.hashicorp.com $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/hashicorp.list
|
|
||||||
sudo apt-get update && sudo apt-get install -y terraform=1.9.*
|
|
||||||
- name: Apply CI VPC
|
|
||||||
working-directory: terraform/ci-vpc
|
|
||||||
env:
|
|
||||||
AWS_ACCESS_KEY_ID: ${{ secrets.NOVA_AWS_ACCESS_KEY_ID }}
|
|
||||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.NOVA_AWS_SECRET_ACCESS_KEY }}
|
|
||||||
AWS_DEFAULT_REGION: us-east-1
|
|
||||||
run: |
|
|
||||||
terraform init -input=false -lock=false
|
|
||||||
terraform apply -auto-approve -lock=false
|
|
||||||
|
|
||||||
# L1 lifecycle matrix: apply simple → apply complex (modify) → destroy
|
|
||||||
lifecycle:
|
|
||||||
name: L1 lifecycle (${{ matrix.module }})
|
|
||||||
needs: ci-vpc-apply
|
|
||||||
if: always()
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
strategy:
|
|
||||||
fail-fast: false
|
|
||||||
matrix:
|
|
||||||
module: [s3, kms-key, ecr, ecs-cluster, iam-role, cloudfront, waf, vpc, alb, ecs-service, rds, uptime]
|
|
||||||
env:
|
|
||||||
NOVA_LIFECYCLE_MODE: ${{ github.event.inputs.lifecycle_mode || vars.NOVA_LIFECYCLE_MODE || 'plan' }}
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- name: Free disk space
|
|
||||||
run: |
|
|
||||||
sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc /usr/local/share/boost
|
|
||||||
sudo apt-get clean
|
|
||||||
df -h /
|
|
||||||
- uses: actions/setup-python@v5
|
|
||||||
with:
|
|
||||||
python-version: "3.12"
|
|
||||||
- name: Install dependencies
|
|
||||||
run: pip install jsonschema pyyaml boto3
|
|
||||||
- name: Install Terraform 1.9.*
|
|
||||||
run: |
|
|
||||||
wget -qO- https://apt.releases.hashicorp.com/gpg | sudo gpg --dearmor -o /usr/share/keyrings/hashicorp.gpg
|
|
||||||
echo "deb [signed-by=/usr/share/keyrings/hashicorp.gpg] https://apt.releases.hashicorp.com $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/hashicorp.list
|
|
||||||
sudo apt-get update && sudo apt-get install -y terraform=1.9.*
|
|
||||||
- name: Read CI VPC outputs
|
|
||||||
if: ${{ env.NOVA_LIFECYCLE_MODE == 'full' }}
|
|
||||||
working-directory: terraform/ci-vpc
|
|
||||||
env:
|
|
||||||
AWS_ACCESS_KEY_ID: ${{ secrets.NOVA_AWS_ACCESS_KEY_ID }}
|
|
||||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.NOVA_AWS_SECRET_ACCESS_KEY }}
|
|
||||||
AWS_DEFAULT_REGION: us-east-1
|
|
||||||
run: |
|
|
||||||
terraform init -input=false -lock=false
|
|
||||||
terraform output -json > /tmp/ci-vpc-outputs.json
|
|
||||||
- name: Apply (simple)
|
|
||||||
env:
|
|
||||||
AWS_ACCESS_KEY_ID: ${{ secrets.NOVA_AWS_ACCESS_KEY_ID }}
|
|
||||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.NOVA_AWS_SECRET_ACCESS_KEY }}
|
|
||||||
AWS_DEFAULT_REGION: us-east-1
|
|
||||||
run: bash scripts/run_lifecycle_test.sh ${{ matrix.module }} simple /tmp/ci-vpc-outputs.json
|
|
||||||
- name: Modify (complex)
|
|
||||||
env:
|
|
||||||
AWS_ACCESS_KEY_ID: ${{ secrets.NOVA_AWS_ACCESS_KEY_ID }}
|
|
||||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.NOVA_AWS_SECRET_ACCESS_KEY }}
|
|
||||||
AWS_DEFAULT_REGION: us-east-1
|
|
||||||
run: bash scripts/run_lifecycle_test.sh ${{ matrix.module }} complex /tmp/ci-vpc-outputs.json
|
|
||||||
- name: Destroy
|
|
||||||
env:
|
|
||||||
AWS_ACCESS_KEY_ID: ${{ secrets.NOVA_AWS_ACCESS_KEY_ID }}
|
|
||||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.NOVA_AWS_SECRET_ACCESS_KEY }}
|
|
||||||
AWS_DEFAULT_REGION: us-east-1
|
|
||||||
run: bash scripts/run_lifecycle_destroy.sh ${{ matrix.module }} /tmp/ci-vpc-outputs.json
|
|
||||||
|
|
||||||
# L2 lifecycle matrix: apply simple → apply complex (modify) → destroy
|
|
||||||
l2-lifecycle:
|
|
||||||
name: L2 lifecycle (${{ matrix.module }})
|
|
||||||
needs: ci-vpc-apply
|
|
||||||
if: always()
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
strategy:
|
|
||||||
fail-fast: false
|
|
||||||
matrix:
|
|
||||||
module: [static-assets, microservice]
|
|
||||||
env:
|
|
||||||
NOVA_LIFECYCLE_MODE: ${{ github.event.inputs.lifecycle_mode || vars.NOVA_LIFECYCLE_MODE || 'plan' }}
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- name: Free disk space
|
|
||||||
run: |
|
|
||||||
sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc /usr/local/share/boost
|
|
||||||
sudo apt-get clean
|
|
||||||
df -h /
|
|
||||||
- uses: actions/setup-python@v5
|
|
||||||
with:
|
|
||||||
python-version: "3.12"
|
|
||||||
- name: Install dependencies
|
|
||||||
run: pip install jsonschema pyyaml boto3
|
|
||||||
- name: Install Terraform 1.9.*
|
|
||||||
run: |
|
|
||||||
wget -qO- https://apt.releases.hashicorp.com/gpg | sudo gpg --dearmor -o /usr/share/keyrings/hashicorp.gpg
|
|
||||||
echo "deb [signed-by=/usr/share/keyrings/hashicorp.gpg] https://apt.releases.hashicorp.com $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/hashicorp.list
|
|
||||||
sudo apt-get update && sudo apt-get install -y terraform=1.9.*
|
|
||||||
- name: Read CI VPC outputs
|
|
||||||
if: ${{ env.NOVA_LIFECYCLE_MODE == 'full' }}
|
|
||||||
working-directory: terraform/ci-vpc
|
|
||||||
env:
|
|
||||||
AWS_ACCESS_KEY_ID: ${{ secrets.NOVA_AWS_ACCESS_KEY_ID }}
|
|
||||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.NOVA_AWS_SECRET_ACCESS_KEY }}
|
|
||||||
AWS_DEFAULT_REGION: us-east-1
|
|
||||||
run: |
|
|
||||||
terraform init -input=false -lock=false
|
|
||||||
terraform output -json > /tmp/ci-vpc-outputs.json
|
|
||||||
- name: Apply (simple)
|
|
||||||
env:
|
|
||||||
AWS_ACCESS_KEY_ID: ${{ secrets.NOVA_AWS_ACCESS_KEY_ID }}
|
|
||||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.NOVA_AWS_SECRET_ACCESS_KEY }}
|
|
||||||
AWS_DEFAULT_REGION: us-east-1
|
|
||||||
run: bash scripts/run_l2_lifecycle_test.sh ${{ matrix.module }} simple /tmp/ci-vpc-outputs.json
|
|
||||||
- name: Modify (complex)
|
|
||||||
env:
|
|
||||||
AWS_ACCESS_KEY_ID: ${{ secrets.NOVA_AWS_ACCESS_KEY_ID }}
|
|
||||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.NOVA_AWS_SECRET_ACCESS_KEY }}
|
|
||||||
AWS_DEFAULT_REGION: us-east-1
|
|
||||||
run: bash scripts/run_l2_lifecycle_test.sh ${{ matrix.module }} complex /tmp/ci-vpc-outputs.json
|
|
||||||
- name: Destroy
|
|
||||||
env:
|
|
||||||
AWS_ACCESS_KEY_ID: ${{ secrets.NOVA_AWS_ACCESS_KEY_ID }}
|
|
||||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.NOVA_AWS_SECRET_ACCESS_KEY }}
|
|
||||||
AWS_DEFAULT_REGION: us-east-1
|
|
||||||
run: bash scripts/run_l2_lifecycle_destroy.sh ${{ matrix.module }} /tmp/ci-vpc-outputs.json
|
|
||||||
|
|
||||||
# Cleanup: destroy the CI VPC (always runs in full mode, even if lifecycle fails)
|
|
||||||
ci-vpc-destroy:
|
|
||||||
name: CI VPC destroy
|
|
||||||
needs: [lifecycle, l2-lifecycle]
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
if: ${{ always() && github.event.inputs.lifecycle_mode != 'plan' && vars.NOVA_LIFECYCLE_MODE != 'plan' }}
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- name: Install Terraform 1.9.*
|
|
||||||
run: |
|
|
||||||
wget -qO- https://apt.releases.hashicorp.com/gpg | sudo gpg --dearmor -o /usr/share/keyrings/hashicorp.gpg
|
|
||||||
echo "deb [signed-by=/usr/share/keyrings/hashicorp.gpg] https://apt.releases.hashicorp.com $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/hashicorp.list
|
|
||||||
sudo apt-get update && sudo apt-get install -y terraform=1.9.*
|
|
||||||
- name: Destroy CI VPC
|
|
||||||
working-directory: terraform/ci-vpc
|
|
||||||
env:
|
|
||||||
AWS_ACCESS_KEY_ID: ${{ secrets.NOVA_AWS_ACCESS_KEY_ID }}
|
|
||||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.NOVA_AWS_SECRET_ACCESS_KEY }}
|
|
||||||
AWS_DEFAULT_REGION: us-east-1
|
|
||||||
run: |
|
|
||||||
terraform init -input=false -lock=false
|
|
||||||
terraform destroy -auto-approve -lock=false
|
|
||||||
@@ -1,165 +0,0 @@
|
|||||||
# 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
|
|
||||||
# <dev-forge>/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
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
# Nova AWS key rotation — platform-managed scheduled pipeline (SPEC §5.9)
|
|
||||||
#
|
|
||||||
# Rotates the NOVA_AWS_* static key daily (no long-lived keys in the steady
|
|
||||||
# state). v0.2 scope: the mechanism must exist (SPEC §5.9); the v0.2 deploy
|
|
||||||
# uses the currently-active key. The rotation is best-effort + idempotent
|
|
||||||
# (scripts/rotate_spike_key.sh deactivates the old key only after the new
|
|
||||||
# key propagates to the consumer's Actions secret store).
|
|
||||||
#
|
|
||||||
# Auth: the rotation uses the CURRENT NOVA_AWS_* key to authenticate to IAM
|
|
||||||
# (the root account 581513795199 can rotate its own keys — confirmed by the
|
|
||||||
# bootstrap). The aws-actions/configure-aws-credentials@v4 step uses the
|
|
||||||
# static-key path (no OIDC role-to-assume); the long-lived key rotates
|
|
||||||
# itself, which is the bootstrap-exception documented in §5.9.
|
|
||||||
#
|
|
||||||
# Forge coords (base URL / owner / consumer repo) are sourced from
|
|
||||||
# repository secrets — NOVA_FORGE_BASE_URL, NOVA_FORGE_OWNER,
|
|
||||||
# NOVA_CONSUMER_REPO — so the synced workflow file stays forge-agnostic
|
|
||||||
# (REQ-230). The rotation script uploads the new key to the consumer's
|
|
||||||
# Actions secret store (the consumer whose deploy.yml consumes NOVA_AWS_*
|
|
||||||
# via secrets: inherit).
|
|
||||||
name: nova-rotate-aws-key
|
|
||||||
|
|
||||||
on:
|
|
||||||
schedule:
|
|
||||||
- cron: "0 0 * * *" # daily at 00:00 UTC
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
id-token: write
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
rotate:
|
|
||||||
name: Rotate NOVA_AWS_* static key
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Check out Nova platform repo
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Configure AWS credentials (bootstrap root creds for IAM key rotation)
|
|
||||||
uses: aws-actions/configure-aws-credentials@v4
|
|
||||||
with:
|
|
||||||
aws-region: ${{ secrets.AWS_DEFAULT_REGION || 'us-east-1' }}
|
|
||||||
access-key-id: ${{ secrets.NOVA_AWS_ACCESS_KEY_ID }}
|
|
||||||
secret-access-key: ${{ secrets.NOVA_AWS_SECRET_ACCESS_KEY }}
|
|
||||||
|
|
||||||
- name: Install Python deps (boto3 for the rotation script)
|
|
||||||
run: |
|
|
||||||
python3 -m pip install --break-system-packages --quiet boto3
|
|
||||||
|
|
||||||
- name: Run the key rotation script
|
|
||||||
env:
|
|
||||||
# aws-actions/configure-aws-credentials exports AWS_ACCESS_KEY_ID /
|
|
||||||
# AWS_SECRET_ACCESS_KEY; the rotation script reads the bootstrap
|
|
||||||
# creds via NOVA_BOOTSTRAP_AWS_* (its dual-read contract, D-034).
|
|
||||||
# Map the standard AWS_* exports onto the script's expected vars.
|
|
||||||
NOVA_BOOTSTRAP_AWS_ACCESS_KEY_ID: ${{ env.AWS_ACCESS_KEY_ID }}
|
|
||||||
NOVA_BOOTSTRAP_AWS_SECRET_ACCESS_KEY: ${{ env.AWS_SECRET_ACCESS_KEY }}
|
|
||||||
# Forge + consumer coords come from repository secrets (REQ-230 —
|
|
||||||
# no forge hostnames/orgs hardcoded in the synced workflow file).
|
|
||||||
# NOVA_FORGE_TOKEN holds the forge API token (set equal to the
|
|
||||||
# existing forge token as a one-time secret setup).
|
|
||||||
NOVA_FORGE_TOKEN: ${{ secrets.NOVA_FORGE_TOKEN }}
|
|
||||||
NOVA_FORGE_BASE_URL: ${{ secrets.NOVA_FORGE_BASE_URL }}
|
|
||||||
NOVA_FORGE_OWNER: ${{ secrets.NOVA_FORGE_OWNER }}
|
|
||||||
NOVA_CONSUMER_REPO: ${{ secrets.NOVA_CONSUMER_REPO }}
|
|
||||||
AWS_DEFAULT_REGION: ${{ secrets.AWS_DEFAULT_REGION || 'us-east-1' }}
|
|
||||||
run: |
|
|
||||||
bash scripts/rotate_spike_key.sh
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
# Nova Slides Render — re-renders presentation deck when source files change.
|
|
||||||
# REQ-273: install python-pptx, pin CLI versions, stage HTML + both PPTX +
|
|
||||||
# base64-inlined images.
|
|
||||||
name: Nova Slides Render
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
paths:
|
|
||||||
- 'docs/presentations/**'
|
|
||||||
- 'scripts/render_slides.sh'
|
|
||||||
- 'scripts/inline_images.py'
|
|
||||||
- 'scripts/render_pptx.py'
|
|
||||||
- 'pyproject.toml'
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
render:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
with: { fetch-depth: 0 }
|
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with: { node-version: '20' }
|
|
||||||
- uses: actions/setup-python@v5
|
|
||||||
with:
|
|
||||||
python-version: '3.10'
|
|
||||||
- name: Install python-pptx (slides extra)
|
|
||||||
run: pip install -e ".[slides]"
|
|
||||||
- name: Install + pin render CLIs
|
|
||||||
run: |
|
|
||||||
npx --yes @marp-team/marp-cli@4.5.0 --version
|
|
||||||
npx --yes @mermaid-js/mermaid-cli@11.16.0 --version
|
|
||||||
- name: Render slides
|
|
||||||
run: bash scripts/render_slides.sh
|
|
||||||
- name: Commit rendered artifacts
|
|
||||||
run: |
|
|
||||||
git config user.name "nova-slides-bot"
|
|
||||||
git config user.email "bot@nova.local"
|
|
||||||
git add docs/presentations/*.html \
|
|
||||||
docs/presentations/*.pptx \
|
|
||||||
docs/presentations/*-python.pptx \
|
|
||||||
docs/presentations/assets/png/*.png
|
|
||||||
git diff --cached --quiet || git commit -m "chore(slides): re-render deck [skip ci]"
|
|
||||||
git push
|
|
||||||
@@ -5,8 +5,10 @@ platform. 3 are generated from `workflows-src/<name>`; 4 are GitHub-only.
|
|||||||
|
|
||||||
## Shared workflows (generated from source)
|
## Shared workflows (generated from source)
|
||||||
|
|
||||||
These 3 are generated from `workflows-src/<name>`. Run `python3 scripts/sync_workflows.py --check` to verify
|
These 3 are generated from `workflows-src/<name>`. D-232 (v1.29): the
|
||||||
no drift.
|
byte-identical forge-parity generator (`scripts/sync_workflows.py`) was
|
||||||
|
removed with the dev-forge parity retirement — the `workflows-src/`
|
||||||
|
copies remain as the source of truth but are no longer auto-synced.
|
||||||
|
|
||||||
| Workflow | Trigger | Inputs | Required Secrets | Purpose |
|
| Workflow | Trigger | Inputs | Required Secrets | Purpose |
|
||||||
|----------|---------|--------|------------------|---------|
|
|----------|---------|--------|------------------|---------|
|
||||||
|
|||||||
@@ -22,6 +22,27 @@ on:
|
|||||||
branches: [main]
|
branches: [main]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
forge-parity-disabled:
|
||||||
|
name: forge_parity_disabled
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Assert forge_parity_disabled
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
# Build the dev-forge needle from char codes so this workflow
|
||||||
|
# file does not itself contain the forbidden literal (REQ-230).
|
||||||
|
needle="$(printf '\x67\x69\x74\x65\x61')"
|
||||||
|
if [ -d ".${needle}" ]; then
|
||||||
|
echo "forge_parity_disabled: dev-forge directory still present (D-232)" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if grep -rqi "$needle" .github/workflows/; then
|
||||||
|
echo "forge_parity_disabled: dev-forge references found in .github/workflows/ (D-232)" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "forge_parity_disabled: OK"
|
||||||
|
|
||||||
lint:
|
lint:
|
||||||
name: Lint
|
name: Lint
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "nova"
|
name = "nova"
|
||||||
version = "1.14.0"
|
version = "1.29.0"
|
||||||
description = "Nova — consumers declare intent; the platform delivers safe production deployment."
|
description = "Nova — consumers declare intent; the platform delivers safe production deployment."
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|||||||
@@ -1,96 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""scripts/attach_release_asset.py — upload one or more files as Gitea release
|
|
||||||
attachments.
|
|
||||||
|
|
||||||
REQ-228 (v1.18): PPTX (and any deck artifact) is attached to the phase's
|
|
||||||
Gitea release. Uses the Gitea API:
|
|
||||||
POST /api/v1/repos/{owner}/{repo}/releases/{id}/assets
|
|
||||||
multipart form: name=<filename>, attachment=<file bytes>
|
|
||||||
|
|
||||||
REQ-270 (v1.23): supports dual PPTX attachment — the MARP PPTX (primary,
|
|
||||||
attached first) and the python-pptx PPTX (comparison artifact). Multiple
|
|
||||||
file paths are accepted; the first is the primary attachment.
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
python3 scripts/attach_release_asset.py <file-path> <release-id>
|
|
||||||
python3 scripts/attach_release_asset.py <file-path> <file-path-2>... <release-id>
|
|
||||||
python3 scripts/attach_release_asset.py docs/presentations/nova-autonomous-cloud-delivery.pptx 522
|
|
||||||
python3 scripts/attach_release_asset.py \
|
|
||||||
docs/presentations/nova-autonomous-cloud-delivery.pptx \
|
|
||||||
docs/presentations/nova-autonomous-cloud-delivery-python.pptx 522
|
|
||||||
|
|
||||||
The last positional argument is always the release id; every preceding
|
|
||||||
argument is an asset path (backward compatible with the single-asset call).
|
|
||||||
|
|
||||||
Token resolution: reads NOVA_GITEA_TOKEN (or ACDL_GITEA_TOKEN) from .env.secrets
|
|
||||||
/ .env, matching the ship_phase.sh pattern. Never uses shell env tokens.
|
|
||||||
"""
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
import json
|
|
||||||
import urllib.request
|
|
||||||
import urllib.error
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
GITEA_BASE = "https://git.cloudinit.dev"
|
|
||||||
OWNER = "continuous-intelligence"
|
|
||||||
REPO = "acdl"
|
|
||||||
|
|
||||||
|
|
||||||
def resolve_token() -> str:
|
|
||||||
for fn in (".env.secrets", ".env"):
|
|
||||||
try:
|
|
||||||
for line in Path(fn).read_text().splitlines():
|
|
||||||
if line.startswith("NOVA_GITEA_TOKEN=") or line.startswith("ACDL_GITEA_TOKEN="):
|
|
||||||
return line.split("=", 1)[1].strip()
|
|
||||||
except (FileNotFoundError, PermissionError):
|
|
||||||
continue
|
|
||||||
raise RuntimeError("No Gitea token found in .env.secrets or .env (NOVA_GITEA_TOKEN/ACDL_GITEA_TOKEN)")
|
|
||||||
|
|
||||||
|
|
||||||
def attach_asset(file_path: str, release_id: str) -> dict:
|
|
||||||
token = resolve_token()
|
|
||||||
p = Path(file_path)
|
|
||||||
if not p.is_file():
|
|
||||||
raise FileNotFoundError(f"Asset file not found: {file_path}")
|
|
||||||
|
|
||||||
url = f"{GITEA_BASE}/api/v1/repos/{OWNER}/{REPO}/releases/{release_id}/assets"
|
|
||||||
filename = p.name
|
|
||||||
|
|
||||||
boundary = "----NovaBoundary7MAgYbk"
|
|
||||||
body = (
|
|
||||||
f"--{boundary}\r\n"
|
|
||||||
f'Content-Disposition: form-data; name="name"\r\n\r\n'
|
|
||||||
f"{filename}\r\n"
|
|
||||||
f"--{boundary}\r\n"
|
|
||||||
f'Content-Disposition: form-data; name="attachment"; filename="{filename}"\r\n'
|
|
||||||
f"Content-Type: application/octet-stream\r\n\r\n"
|
|
||||||
).encode() + p.read_bytes() + f"\r\n--{boundary}--\r\n".encode()
|
|
||||||
|
|
||||||
req = urllib.request.Request(
|
|
||||||
url,
|
|
||||||
data=body,
|
|
||||||
headers={
|
|
||||||
"Authorization": f"token {token}",
|
|
||||||
"Content-Type": f"multipart/form-data; boundary={boundary}",
|
|
||||||
},
|
|
||||||
method="POST",
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
resp = urllib.request.urlopen(req, timeout=60)
|
|
||||||
return json.loads(resp.read())
|
|
||||||
except urllib.error.HTTPError as e:
|
|
||||||
err = e.read().decode()[:300]
|
|
||||||
raise RuntimeError(f"HTTP {e.code} attaching {filename} to release {release_id}: {err}") from e
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
if len(sys.argv) < 3:
|
|
||||||
print("Usage: attach_release_asset.py <file-path> [<file-path-2>...] <release-id>")
|
|
||||||
sys.exit(1)
|
|
||||||
asset_paths = sys.argv[1:-1]
|
|
||||||
release_id = sys.argv[-1]
|
|
||||||
for idx, path in enumerate(asset_paths):
|
|
||||||
result = attach_asset(path, release_id)
|
|
||||||
primary = " (primary)" if idx == 0 and len(asset_paths) > 1 else ""
|
|
||||||
print(f"Attached{primary}: {result.get('name')} → release {release_id} (asset id {result.get('id')})")
|
|
||||||
+10
-111
@@ -6,25 +6,19 @@
|
|||||||
# 1. List nova-spike-runner's access keys.
|
# 1. List nova-spike-runner's access keys.
|
||||||
# 2. Create a new key.
|
# 2. Create a new key.
|
||||||
# 3. Write the new key to gitignored .env.secrets (chmod 600).
|
# 3. Write the new key to gitignored .env.secrets (chmod 600).
|
||||||
# 4. Upload the new key to the consumer's Actions secret store + verify
|
# 4. Deactivate + delete the old key(s).
|
||||||
# (GET) that it propagated (SPEC §5.9 idempotency).
|
|
||||||
# 5. Deactivate + delete the old key(s) ONLY after the upload is verified.
|
|
||||||
# If the upload/verify fails, the old key stays Active + the run exits
|
|
||||||
# non-zero (the consumer's deploy keeps a working credential).
|
|
||||||
#
|
#
|
||||||
# Env vars (forge coords): NOVA_FORGE_TOKEN / NOVA_FORGE_BASE_URL /
|
# Idempotent: re-running always ends with exactly 1 active key for the user.
|
||||||
# NOVA_FORGE_OWNER / NOVA_CONSUMER_REPO (the scheduled workflow passes these
|
|
||||||
# forge-agnostic names, REQ-230). NOVA_GITEA_* are a backward-compat
|
|
||||||
# fallback for ad-hoc local runs.
|
|
||||||
#
|
|
||||||
# Idempotent: re-running always ends with exactly 1 active key for the user
|
|
||||||
# (once the new key has propagated to the secret store).
|
|
||||||
# Does NOT rotate the bootstrap root key (D-034 closure = manual user step).
|
# Does NOT rotate the bootstrap root key (D-034 closure = manual user step).
|
||||||
#
|
#
|
||||||
# Spike scope (D-039): the spike user key is per-run-rotated; real OIDC is
|
# Spike scope (D-039): the spike user key is per-run-rotated; real OIDC is
|
||||||
# v1.2 (blocked on go-gitea/gitea#36988).
|
# v1.2.
|
||||||
# Nova rebrand (P4, REQ-163): IAM user renamed acdl-spike-runner →
|
# Nova rebrand (P4, REQ-163): IAM user renamed acdl-spike-runner →
|
||||||
# nova-spike-runner.
|
# nova-spike-runner.
|
||||||
|
# D-232 (v1.29): the forge Actions secret-store upload was dev-forge-only
|
||||||
|
# and has been removed with the forge-parity retirement. The rotated key
|
||||||
|
# is written to .env.secrets only; the consumer's deploy reads it from
|
||||||
|
# there.
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
cd "$ROOT"
|
cd "$ROOT"
|
||||||
@@ -72,10 +66,6 @@ new_id = new["AccessKeyId"]
|
|||||||
new_secret = new["SecretAccessKey"]
|
new_secret = new["SecretAccessKey"]
|
||||||
print(f"iam: created new key {new_id} for {user}", file=sys.stderr)
|
print(f"iam: created new key {new_id} for {user}", file=sys.stderr)
|
||||||
|
|
||||||
# Deactivation of the old keys is deferred to AFTER the new key propagates
|
|
||||||
# to the Gitea Actions secret store (SPEC §5.9 idempotency — see below).
|
|
||||||
# Writing .env.secrets first keeps the local operator's working key current.
|
|
||||||
|
|
||||||
# Write the new key to gitignored .env.secrets (chmod 600).
|
# Write the new key to gitignored .env.secrets (chmod 600).
|
||||||
# Nova rebrand (P2): keys are NOVA_*; the ACDL_* legacy keys are the
|
# Nova rebrand (P2): keys are NOVA_*; the ACDL_* legacy keys are the
|
||||||
# dual-read fallback source until P5 (kept as comments in .env.secrets).
|
# dual-read fallback source until P5 (kept as comments in .env.secrets).
|
||||||
@@ -86,106 +76,15 @@ with open(env_file, "w") as fh:
|
|||||||
os.chmod(env_file, 0o600)
|
os.chmod(env_file, 0o600)
|
||||||
print(f"rotated key written to {env_file} (chmod 600)", file=sys.stderr)
|
print(f"rotated key written to {env_file} (chmod 600)", file=sys.stderr)
|
||||||
|
|
||||||
# Upload the new key to the consumer's Actions secret store BEFORE
|
# Deactivate + delete the old keys. The new key is already in .env.secrets
|
||||||
# deactivating the old key (SPEC §5.9 — idempotency: the old key is
|
# so deactivating is safe (D-039 local-rotation contract).
|
||||||
# deactivated only after the new one propagates). If the upload or the
|
|
||||||
# post-upload verification fails, the old key is left Active so the
|
|
||||||
# consumer's deploy still has a working credential; the run exits non-zero
|
|
||||||
# so the scheduled workflow surfaces the failure (rather than silently
|
|
||||||
# stranding the consumer with a key that never reached the secret store).
|
|
||||||
#
|
|
||||||
# Forge + consumer coords come from env vars. The scheduled workflow passes
|
|
||||||
# forge-agnostic NOVA_FORGE_* names (REQ-230 — no forge hostnames in the
|
|
||||||
# synced workflow file); NOVA_GITEA_* are accepted as a backward-compat
|
|
||||||
# fallback for ad-hoc local runs. Defaults keep the legacy platform-repo
|
|
||||||
# target when nothing is set.
|
|
||||||
# Dual-read token: NOVA_FORGE_TOKEN preferred, NOVA_GITEA_TOKEN fallback (G-106).
|
|
||||||
gitea_token = os.environ.get("NOVA_FORGE_TOKEN") or os.environ.get("NOVA_GITEA_TOKEN")
|
|
||||||
gitea_base = (
|
|
||||||
os.environ.get("NOVA_FORGE_BASE_URL")
|
|
||||||
or os.environ.get("NOVA_GITEA_BASE_URL")
|
|
||||||
or "https://git.cloudinit.dev"
|
|
||||||
).rstrip("/")
|
|
||||||
gitea_owner = (
|
|
||||||
os.environ.get("NOVA_FORGE_OWNER")
|
|
||||||
or os.environ.get("NOVA_GITEA_OWNER")
|
|
||||||
or "continuous-intelligence"
|
|
||||||
)
|
|
||||||
gitea_repo = (
|
|
||||||
os.environ.get("NOVA_CONSUMER_REPO")
|
|
||||||
or os.environ.get("NOVA_GITEA_REPO")
|
|
||||||
or "acdl"
|
|
||||||
)
|
|
||||||
secrets_api = f"{gitea_base}/api/v1/repos/{gitea_owner}/{gitea_repo}/actions/secrets"
|
|
||||||
|
|
||||||
if gitea_token:
|
|
||||||
import urllib.request
|
|
||||||
import urllib.error
|
|
||||||
import time
|
|
||||||
|
|
||||||
def _put_secret(name, value):
|
|
||||||
req = urllib.request.Request(
|
|
||||||
f"{secrets_api}/{name}",
|
|
||||||
data=json.dumps({"value": value}).encode(),
|
|
||||||
method="PUT",
|
|
||||||
headers={"Authorization": f"token {gitea_token}",
|
|
||||||
"Content-Type": "application/json"},
|
|
||||||
)
|
|
||||||
urllib.request.urlopen(req).read()
|
|
||||||
print(f"gitea: secret {name} uploaded to {gitea_owner}/{gitea_repo}", file=sys.stderr)
|
|
||||||
|
|
||||||
def _verify_secret(name):
|
|
||||||
# Gitea does not return secret *values*; a 200 confirms the secret
|
|
||||||
# exists with the expected name. Retry briefly so eventual
|
|
||||||
# consistency on the secrets API settles (observed sub-second lag).
|
|
||||||
for attempt in range(5):
|
|
||||||
req = urllib.request.Request(
|
|
||||||
f"{secrets_api}/{name}",
|
|
||||||
method="GET",
|
|
||||||
headers={"Authorization": f"token {gitea_token}"},
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
with urllib.request.urlopen(req) as resp:
|
|
||||||
if resp.status == 200:
|
|
||||||
print(f"gitea: secret {name} verified present", file=sys.stderr)
|
|
||||||
return True
|
|
||||||
except urllib.error.HTTPError as e:
|
|
||||||
if e.code == 404:
|
|
||||||
time.sleep(0.5)
|
|
||||||
continue
|
|
||||||
raise
|
|
||||||
return False
|
|
||||||
|
|
||||||
try:
|
|
||||||
_put_secret("NOVA_AWS_ACCESS_KEY_ID", new_id)
|
|
||||||
_put_secret("NOVA_AWS_SECRET_ACCESS_KEY", new_secret)
|
|
||||||
ok = _verify_secret("NOVA_AWS_ACCESS_KEY_ID") and \
|
|
||||||
_verify_secret("NOVA_AWS_SECRET_ACCESS_KEY")
|
|
||||||
if not ok:
|
|
||||||
raise RuntimeError("gitea secret verification failed (404 after PUT)")
|
|
||||||
except Exception as e:
|
|
||||||
# Upload/verify failed: leave the old key Active so the consumer's
|
|
||||||
# deploy still works. Surface non-zero so the schedule is noisy.
|
|
||||||
print(f"gitea: secret upload/verify FAILED ({e}); old key left Active", file=sys.stderr)
|
|
||||||
sys.exit(2)
|
|
||||||
else:
|
|
||||||
print("gitea: NOVA_FORGE_TOKEN/NOVA_GITEA_TOKEN not set; secret upload skipped (v1.2 hardening)", file=sys.stderr)
|
|
||||||
# No forge target → the new key is already in .env.secrets, so the
|
|
||||||
# operator's local env works. The old key is deactivated below so the
|
|
||||||
# user ends with exactly 1 active key (D-039 local-rotation contract).
|
|
||||||
|
|
||||||
# Deactivate + delete the old keys. When a forge token was set, this runs
|
|
||||||
# ONLY after the new key propagated to the consumer's secret store (the
|
|
||||||
# sys.exit(2) above prevents reaching here on upload/verify failure). When
|
|
||||||
# no token was set, the new key is already in .env.secrets so deactivating
|
|
||||||
# is safe (D-039 local-rotation contract).
|
|
||||||
for k in active:
|
for k in active:
|
||||||
old_id = k["AccessKeyId"]
|
old_id = k["AccessKeyId"]
|
||||||
if old_id == new_id:
|
if old_id == new_id:
|
||||||
continue
|
continue
|
||||||
iam.update_access_key(UserName=user, AccessKeyId=old_id, Status="Inactive")
|
iam.update_access_key(UserName=user, AccessKeyId=old_id, Status="Inactive")
|
||||||
iam.delete_access_key(UserName=user, AccessKeyId=old_id)
|
iam.delete_access_key(UserName=user, AccessKeyId=old_id)
|
||||||
print(f"iam: deactivated+deleted old key {old_id} (after propagation)", file=sys.stderr)
|
print(f"iam: deactivated+deleted old key {old_id}", file=sys.stderr)
|
||||||
|
|
||||||
print(f"OK: {user} now has exactly 1 active key: {new_id}")
|
print(f"OK: {user} now has exactly 1 active key: {new_id}")
|
||||||
PY
|
PY
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# scripts/ship_phase.sh — internal CIAgent per-phase ship helper (v1.16)
|
|
||||||
# Usage: bash scripts/ship_phase.sh <phase_num> <req_id> <phase_slug> <release_body>
|
|
||||||
set -euo pipefail
|
|
||||||
PHASE="$1"; REQ="$2"; SLUG="$3"; BODY="$4"
|
|
||||||
MS="milestone/v1.16-nova-simplification"
|
|
||||||
BR="phase/$(printf '%02d' "$PHASE")-${SLUG}"
|
|
||||||
cd "$(git rev-parse --show-toplevel)"
|
|
||||||
git checkout "$MS" 2>/dev/null
|
|
||||||
git merge --squash "$BR" 2>&1 | tail -2
|
|
||||||
MSG="verify(P${PHASE}): ${SLUG} — 4-layer verify PASS + ship
|
|
||||||
|
|
||||||
${BODY}
|
|
||||||
|
|
||||||
---ci---
|
|
||||||
project: acdl
|
|
||||||
phase: ${PHASE}
|
|
||||||
milestone: v1.16
|
|
||||||
status: complete
|
|
||||||
phase_role: execution
|
|
||||||
requirements:
|
|
||||||
covered: [${REQ}]
|
|
||||||
partial: []
|
|
||||||
---/ci---"
|
|
||||||
git commit -q -m "$MSG"
|
|
||||||
PREV=$(git tag -l "v1.15.*" --sort=-version:refname | head -1)
|
|
||||||
PATCH=$(($(echo "$PREV" | sed 's/v1.15.//')))
|
|
||||||
NEWPATCH=$((PATCH + 1))
|
|
||||||
TAG="v1.15.${NEWPATCH}"
|
|
||||||
git tag -a "$TAG" -m "${TAG}: v1.16 P${PHASE} — ${SLUG}"
|
|
||||||
git push origin "$MS" --tags 2>&1 | grep -E "new tag|new branch" | head -2
|
|
||||||
python3 - "$TAG" "$PREV" <<'PYEOF'
|
|
||||||
import json, subprocess, sys, urllib.request, urllib.error
|
|
||||||
tag, prev = sys.argv[1], sys.argv[2]
|
|
||||||
tok = [l.split("=",1)[1].strip() for l in open(".env.secrets") if l.startswith("NOVA_GITEA_TOKEN=")][0]
|
|
||||||
body = subprocess.check_output(["git","log",f"{prev}..{tag}","--oneline"]).decode()
|
|
||||||
payload = {"tag_name":tag,"name":f"Nova {tag} — v1.16 P{tag.split('.')[-1]}","body":body}
|
|
||||||
req = urllib.request.Request("https://git.cloudinit.dev/api/v1/repos/continuous-intelligence/acdl/releases", data=json.dumps(payload).encode(), headers={"Authorization":f"token {tok}","Content-Type":"application/json"}, method="POST")
|
|
||||||
try:
|
|
||||||
r = urllib.request.urlopen(req, timeout=30); d = json.loads(r.read()); print(f"release_id: {d.get('id')} tag: {tag}")
|
|
||||||
except urllib.error.HTTPError as e:
|
|
||||||
if e.code == 409: print(f"release exists for {tag}")
|
|
||||||
else: print(f"HTTP {e.code}: {e.read().decode()[:120]}")
|
|
||||||
except Exception as e: print(f"ERROR: {e}")
|
|
||||||
PYEOF
|
|
||||||
echo "SHIPPED ${TAG}"
|
|
||||||
@@ -102,7 +102,6 @@ DOMAINS=(
|
|||||||
EXCLUDE_SCRIPTS=(
|
EXCLUDE_SCRIPTS=(
|
||||||
sync_to_gl.sh
|
sync_to_gl.sh
|
||||||
sync_to_nova.sh
|
sync_to_nova.sh
|
||||||
ship_phase.sh
|
|
||||||
update_atelier_vendor.sh
|
update_atelier_vendor.sh
|
||||||
post_stage_comment.sh
|
post_stage_comment.sh
|
||||||
rotate_spike_key.sh
|
rotate_spike_key.sh
|
||||||
@@ -114,8 +113,6 @@ EXCLUDE_SCRIPTS=(
|
|||||||
untag_acdl_keys.py
|
untag_acdl_keys.py
|
||||||
seed_uptime_monitors.py
|
seed_uptime_monitors.py
|
||||||
push_consumer_image.py
|
push_consumer_image.py
|
||||||
sync_workflows.py
|
|
||||||
attach_release_asset.py
|
|
||||||
check_north_star_diff.sh
|
check_north_star_diff.sh
|
||||||
render_slides.sh
|
render_slides.sh
|
||||||
)
|
)
|
||||||
@@ -198,7 +195,6 @@ echo ""
|
|||||||
# Hidden dirs/files in SRC that are NOT consumer-facing. .github is kept.
|
# Hidden dirs/files in SRC that are NOT consumer-facing. .github is kept.
|
||||||
EXCLUDES=(
|
EXCLUDES=(
|
||||||
--exclude=/.ciagent
|
--exclude=/.ciagent
|
||||||
--exclude=/.gitea
|
|
||||||
--exclude=/.env
|
--exclude=/.env
|
||||||
--exclude=/.env.secrets
|
--exclude=/.env.secrets
|
||||||
--exclude=/.coverage
|
--exclude=/.coverage
|
||||||
|
|||||||
@@ -1,83 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Sync byte-identical workflows from workflows-src/ to .gitea/ + .github/ (P8, REQ-172).
|
|
||||||
|
|
||||||
Three workflow pairs are byte-identical Gitea + GitHub mirrors:
|
|
||||||
ci.yml, deploy.yml, modules-lifecycle.yml, rotate-aws-key.yml.
|
|
||||||
|
|
||||||
This generator reads the single source from ``workflows-src/<name>`` and
|
|
||||||
writes byte-identical copies to both ``.gitea/workflows/<name>`` and
|
|
||||||
``.github/workflows/<name>``. Use ``--check`` to verify the committed
|
|
||||||
files match the generated output (CI gate); use ``--write`` to regenerate
|
|
||||||
the committed files from the sources.
|
|
||||||
|
|
||||||
The 4 GitHub-only workflows (platform-test.yml, primitives-plan.yml,
|
|
||||||
patterns-plan.yml, release.yml) have no Gitea mirror (act_runner feature
|
|
||||||
gaps) and are NOT touched by this generator.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import filecmp
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parent.parent
|
|
||||||
SRC_DIR = ROOT / "workflows-src"
|
|
||||||
GITEA_DIR = ROOT / ".gitea" / "workflows"
|
|
||||||
GITHUB_DIR = ROOT / ".github" / "workflows"
|
|
||||||
|
|
||||||
PAIRS = ["ci.yml", "deploy.yml", "modules-lifecycle.yml", "rotate-aws-key.yml"]
|
|
||||||
|
|
||||||
|
|
||||||
def _read_source(name: str) -> str:
|
|
||||||
src = SRC_DIR / name
|
|
||||||
if not src.is_file():
|
|
||||||
raise FileNotFoundError(f"source {src} missing")
|
|
||||||
return src.read_text()
|
|
||||||
|
|
||||||
|
|
||||||
def check() -> int:
|
|
||||||
"""Verify committed files match the sources. Exit 0 if clean, 1 if drift."""
|
|
||||||
drift = []
|
|
||||||
for name in PAIRS:
|
|
||||||
content = _read_source(name)
|
|
||||||
for dest_dir in (GITEA_DIR, GITHUB_DIR):
|
|
||||||
dest = dest_dir / name
|
|
||||||
if not dest.is_file():
|
|
||||||
drift.append(f"{dest} MISSING (expected from workflows-src/{name})")
|
|
||||||
continue
|
|
||||||
if dest.read_text() != content:
|
|
||||||
drift.append(f"{dest} DRIFTED from workflows-src/{name}")
|
|
||||||
if drift:
|
|
||||||
for d in drift:
|
|
||||||
print(f"DRIFT: {d}", file=sys.stderr)
|
|
||||||
print("\nRun: python3 scripts/sync_workflows.py --write", file=sys.stderr)
|
|
||||||
return 1
|
|
||||||
print(f"OK: {len(PAIRS)} workflow pairs match workflows-src/ sources")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
def write() -> int:
|
|
||||||
"""Regenerate .gitea/ + .github/ from workflows-src/ sources."""
|
|
||||||
for name in PAIRS:
|
|
||||||
content = _read_source(name)
|
|
||||||
for dest_dir in (GITEA_DIR, GITHUB_DIR):
|
|
||||||
dest_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
(dest_dir / name).write_text(content)
|
|
||||||
print(f"wrote: .gitea/workflows/{name} + .github/workflows/{name}")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
def main(argv: list[str] | None = None) -> int:
|
|
||||||
parser = argparse.ArgumentParser(description="Sync byte-identical workflow pairs.")
|
|
||||||
group = parser.add_mutually_exclusive_group(required=True)
|
|
||||||
group.add_argument("--check", action="store_true", help="verify committed files match sources (CI gate)")
|
|
||||||
group.add_argument("--write", action="store_true", help="regenerate committed files from sources")
|
|
||||||
args = parser.parse_args(argv)
|
|
||||||
if args.check:
|
|
||||||
return check()
|
|
||||||
return write()
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
sys.exit(main())
|
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
"""NFR-11 / REQ-326 AC: byte-identical Nova CLI composite action.
|
"""NFR-11 / REQ-326 AC: byte-identical Nova CLI composite action.
|
||||||
|
|
||||||
This test verifies the structural invariants of the `nova cli-action`
|
D-232 (v1.29): the byte-identical cross-forge parity is deliberately
|
||||||
composite action at `.github/actions/nova-cli/action.yml`. The action is
|
disabled — the dev-forge mirror was removed and forge parity is no longer
|
||||||
discovered by both the production forge (GitHub Actions) and the dev
|
maintained (forge_parity_disabled). The composite action at
|
||||||
forge (act_runner) via the same `.github/actions/nova-cli/` path, so a
|
`.github/actions/nova-cli/action.yml` is now GitHub-only; the structural
|
||||||
single source file under test guarantees both platforms consume the
|
invariants below remain valid as the unit-testable subset of the action's
|
||||||
same bytes — which is the byte-identical requirement (NFR-11).
|
correctness. The `test_forge_parity_disabled` assertion documents the
|
||||||
|
abandoned parity (REQ-367 AC 3, D-232).
|
||||||
|
|
||||||
What this unit test can verify (structural invariants):
|
What this unit test can verify (structural invariants):
|
||||||
(a) action.yml is valid YAML
|
(a) action.yml is valid YAML
|
||||||
@@ -18,25 +19,8 @@ What this unit test can verify (structural invariants):
|
|||||||
(g) an install step exists that installs `nova` (CodeArtifact default
|
(g) an install step exists that installs `nova` (CodeArtifact default
|
||||||
or fallback-index path)
|
or fallback-index path)
|
||||||
(h) a run step executes `nova ${{ inputs.command }}`
|
(h) a run step executes `nova ${{ inputs.command }}`
|
||||||
|
(i) forge_parity_disabled — the dev-forge mirror dir is absent and no
|
||||||
What this unit test CANNOT verify (and intentionally does not):
|
dev-forge references remain in .github/workflows/ (D-232)
|
||||||
The full byte-identical cross-platform verification (NFR-11,
|
|
||||||
REQ-326 AC2) requires running the action with identical inputs on a
|
|
||||||
production-forge ubuntu-latest runner AND a dev-forge act_runner, then
|
|
||||||
asserting identical stdout + exit code. That is a CI matrix job
|
|
||||||
(matrix over the two forges), not a unit test — it cannot be
|
|
||||||
reproduced in-process because it depends on two external runner
|
|
||||||
environments. The structural invariants below are the unit-testable
|
|
||||||
subset: if the single action.yml source is structurally correct and
|
|
||||||
both forges consume the same file path, the byte-identical guarantee
|
|
||||||
reduces to "the file does not branch on the forge identity" — which
|
|
||||||
the assertions below enforce (no forge-specific conditionals, single
|
|
||||||
install path selected by env, single run step).
|
|
||||||
|
|
||||||
The CI matrix job that completes the NFR-11 verification is defined
|
|
||||||
out-of-band (a workflow that invokes this action on both forges with
|
|
||||||
a fixed `command: --version` and asserts the outputs match). It is
|
|
||||||
not part of this pytest suite.
|
|
||||||
"""
|
"""
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -202,10 +186,9 @@ def test_action_run_step_forwards_mode_and_contract_env():
|
|||||||
|
|
||||||
def test_action_source_contains_no_forge_specific_strings():
|
def test_action_source_contains_no_forge_specific_strings():
|
||||||
"""NFR-11: the single action.yml must not embed forge-specific
|
"""NFR-11: the single action.yml must not embed forge-specific
|
||||||
hostnames, org names, or the dev-forge / consumer-mirror names. Both
|
hostnames, org names, or the dev-forge / consumer-mirror names. This
|
||||||
forges consume the same file, so the file must not branch on the
|
is the unit-testable half of the byte-identical guarantee (still
|
||||||
forge identity. This is the unit-testable half of the byte-identical
|
enforced post-D-232 so the action stays forge-agnostic)."""
|
||||||
guarantee."""
|
|
||||||
text = ACTION.read_text()
|
text = ACTION.read_text()
|
||||||
for needle in _FORBIDDEN:
|
for needle in _FORBIDDEN:
|
||||||
assert needle.lower() not in text.lower(), \
|
assert needle.lower() not in text.lower(), \
|
||||||
@@ -215,7 +198,7 @@ def test_action_source_contains_no_forge_specific_strings():
|
|||||||
def test_action_has_single_install_path_selected_by_env():
|
def test_action_has_single_install_path_selected_by_env():
|
||||||
"""NFR-11: the install step must select CodeArtifact vs fallback by
|
"""NFR-11: the install step must select CodeArtifact vs fallback by
|
||||||
env var at runtime — NOT by a forge-specific conditional. This keeps
|
env var at runtime — NOT by a forge-specific conditional. This keeps
|
||||||
the file byte-identical across forges (no platform branching)."""
|
the file forge-agnostic (no platform branching)."""
|
||||||
a = _load_action()
|
a = _load_action()
|
||||||
steps = a["runs"]["steps"]
|
steps = a["runs"]["steps"]
|
||||||
install = next(
|
install = next(
|
||||||
@@ -233,6 +216,23 @@ def test_action_has_single_install_path_selected_by_env():
|
|||||||
assert needle.lower() not in run.lower()
|
assert needle.lower() not in run.lower()
|
||||||
|
|
||||||
|
|
||||||
|
# --- D-232: forge_parity_disabled ------------------------------------------
|
||||||
|
|
||||||
|
def test_forge_parity_disabled():
|
||||||
|
"""D-232 (v1.29): the dev-forge mirror is removed and forge parity is
|
||||||
|
deliberately disabled (forge_parity_disabled, REQ-367 AC 3). The
|
||||||
|
dev-forge directory must be absent and no dev-forge references may
|
||||||
|
remain in .github/workflows/."""
|
||||||
|
forge_dir = ROOT / f".{_FORGE}"
|
||||||
|
assert not forge_dir.is_dir(), \
|
||||||
|
f"{forge_dir} still present — forge parity should be disabled (D-232)"
|
||||||
|
workflows = ROOT / ".github" / "workflows"
|
||||||
|
for wf in workflows.glob("*"):
|
||||||
|
text = wf.read_text(errors="replace")
|
||||||
|
assert _FORGE.lower() not in text.lower(), \
|
||||||
|
f"{wf} contains a dev-forge reference — parity should be disabled (D-232)"
|
||||||
|
|
||||||
|
|
||||||
# --- documentation: the CI matrix job is out-of-band ------------------------
|
# --- documentation: the CI matrix job is out-of-band ------------------------
|
||||||
|
|
||||||
def test_action_header_documents_byte_identical_matrix_job():
|
def test_action_header_documents_byte_identical_matrix_job():
|
||||||
|
|||||||
@@ -32,14 +32,13 @@ _EXCLUDE = {".ciagent", ".gitea", ".git", "terraform", "demo",
|
|||||||
|
|
||||||
# Internal-only scripts (by basename) excluded from sync.
|
# Internal-only scripts (by basename) excluded from sync.
|
||||||
_EXCLUDE_SCRIPTS = {
|
_EXCLUDE_SCRIPTS = {
|
||||||
"sync_to_gl.sh", "sync_to_nova.sh", "ship_phase.sh",
|
"sync_to_gl.sh", "sync_to_nova.sh",
|
||||||
"update_atelier_vendor.sh", "post_stage_comment.sh",
|
"update_atelier_vendor.sh", "post_stage_comment.sh",
|
||||||
"rotate_spike_key.sh", "run_l2_lifecycle_destroy.sh",
|
"rotate_spike_key.sh", "run_l2_lifecycle_destroy.sh",
|
||||||
"run_lifecycle_destroy.sh", "run_lifecycle_test.sh",
|
"run_lifecycle_destroy.sh", "run_lifecycle_test.sh",
|
||||||
"migrate_dynamodb_data.py", "migrate_ssm_paths.py",
|
"migrate_dynamodb_data.py", "migrate_ssm_paths.py",
|
||||||
"untag_acdl_keys.py", "seed_uptime_monitors.py",
|
"untag_acdl_keys.py", "seed_uptime_monitors.py",
|
||||||
"push_consumer_image.py", "sync_workflows.py",
|
"push_consumer_image.py", "check_north_star_diff.sh",
|
||||||
"attach_release_asset.py", "check_north_star_diff.sh",
|
|
||||||
"render_slides.sh",
|
"render_slides.sh",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -97,15 +97,18 @@ class TestWorkflowConformance:
|
|||||||
def test_github_workflow_exists(self):
|
def test_github_workflow_exists(self):
|
||||||
assert (ROOT / ".github/workflows/ci.yml").is_file()
|
assert (ROOT / ".github/workflows/ci.yml").is_file()
|
||||||
|
|
||||||
def test_sync_workflows_check_passes(self):
|
def test_forge_parity_disabled(self):
|
||||||
"""P8 (REQ-172): sync_workflows.py --check exits 0 (committed
|
"""D-232 (v1.29): the byte-identical forge-parity generator
|
||||||
files match the workflows-src/ sources)."""
|
(scripts/sync_workflows.py) is removed and the dev-forge mirror
|
||||||
import subprocess
|
is gone. Forge parity is deliberately disabled (forge_parity_disabled,
|
||||||
rc = subprocess.call(
|
REQ-367 AC 3). This test asserts that state holds."""
|
||||||
[sys.executable, "scripts/sync_workflows.py", "--check"],
|
# Build the dev-forge dir name from chr() so this file does not
|
||||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
# contain the forbidden literal (REQ-230 self-matching guard).
|
||||||
)
|
_forge = chr(103) + chr(105) + chr(116) + chr(101) + chr(97)
|
||||||
assert rc == 0, "sync_workflows.py --check failed — run scripts/sync_workflows.py --write"
|
assert not (ROOT / "scripts" / "sync_workflows.py").is_file(), \
|
||||||
|
"scripts/sync_workflows.py should be removed (D-232 forge_parity_disabled)"
|
||||||
|
assert not (ROOT / f".{_forge}").is_dir(), \
|
||||||
|
"dev-forge mirror should be removed (D-232 forge_parity_disabled)"
|
||||||
|
|
||||||
class TestRunCiScript:
|
class TestRunCiScript:
|
||||||
def test_run_ci_script_exists_and_executable(self):
|
def test_run_ci_script_exists_and_executable(self):
|
||||||
|
|||||||
@@ -5,7 +5,12 @@ daily. v0.2 scope: the mechanism must *exist* (exists-not-ran); the v0.2
|
|||||||
deploy uses the currently-active key. These tests assert the workflow file
|
deploy uses the currently-active key. These tests assert the workflow file
|
||||||
exists, is valid YAML, declares the schedule + dispatch triggers, invokes
|
exists, is valid YAML, declares the schedule + dispatch triggers, invokes
|
||||||
scripts/rotate_spike_key.sh, uses the static-key auth path (not OIDC), and
|
scripts/rotate_spike_key.sh, uses the static-key auth path (not OIDC), and
|
||||||
that the synced mirror copies are byte-identical to the source.
|
that the GitHub copy matches the workflows-src/ source.
|
||||||
|
|
||||||
|
D-232 (v1.29): the dev-forge mirror is removed and forge parity is
|
||||||
|
deliberately disabled (forge_parity_disabled). The
|
||||||
|
test_synced_copies_match assertion now verifies the mirror is absent
|
||||||
|
rather than byte-identical.
|
||||||
|
|
||||||
This test file is itself synced to the consumer mirror, so it must be
|
This test file is itself synced to the consumer mirror, so it must be
|
||||||
forge-agnostic (REQ-230): the dev-forge directory name + the forge-mention
|
forge-agnostic (REQ-230): the dev-forge directory name + the forge-mention
|
||||||
@@ -87,11 +92,15 @@ def test_workflow_uses_static_key_auth():
|
|||||||
|
|
||||||
|
|
||||||
def test_synced_copies_match():
|
def test_synced_copies_match():
|
||||||
assert GITHUB.is_file(), f"{GITHUB} missing (run scripts/sync_workflows.py --write)"
|
"""D-232 (v1.29): the dev-forge mirror is removed and forge parity is
|
||||||
assert FORGE_MIRROR.is_file(), "mirror copy missing (run scripts/sync_workflows.py --write)"
|
deliberately disabled (forge_parity_disabled, REQ-367 AC 3). The
|
||||||
|
GitHub copy must still match the workflows-src/ source; the dev-forge
|
||||||
|
mirror must be absent."""
|
||||||
|
assert GITHUB.is_file(), f"{GITHUB} missing"
|
||||||
|
assert not FORGE_MIRROR.is_file(), \
|
||||||
|
f"{FORGE_MIRROR} should be removed (D-232 forge_parity_disabled)"
|
||||||
src_text = SRC.read_text()
|
src_text = SRC.read_text()
|
||||||
assert GITHUB.read_text() == src_text, f"{GITHUB} drifted from workflows-src/"
|
assert GITHUB.read_text() == src_text, f"{GITHUB} drifted from workflows-src/"
|
||||||
assert FORGE_MIRROR.read_text() == src_text, "mirror drifted from workflows-src/"
|
|
||||||
|
|
||||||
|
|
||||||
def test_workflow_is_forge_agnostic():
|
def test_workflow_is_forge_agnostic():
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ class TestSyncToNovaScript:
|
|||||||
script = (ROOT / "scripts" / "sync_to_nova.sh").read_text()
|
script = (ROOT / "scripts" / "sync_to_nova.sh").read_text()
|
||||||
# Isolate the EXCLUDE_SCRIPTS=( ... ) block.
|
# Isolate the EXCLUDE_SCRIPTS=( ... ) block.
|
||||||
block = script.split("EXCLUDE_SCRIPTS=(")[1].split(")")[0]
|
block = script.split("EXCLUDE_SCRIPTS=(")[1].split(")")[0]
|
||||||
for internal in ("sync_to_gl.sh", "sync_to_nova.sh", "ship_phase.sh",
|
for internal in ("sync_to_gl.sh", "sync_to_nova.sh",
|
||||||
"update_atelier_vendor.sh", "rotate_spike_key.sh",
|
"update_atelier_vendor.sh", "rotate_spike_key.sh",
|
||||||
"post_stage_comment.sh", "untag_acdl_keys.py"):
|
"post_stage_comment.sh", "untag_acdl_keys.py"):
|
||||||
assert internal in block, f"{internal} missing from EXCLUDE_SCRIPTS"
|
assert internal in block, f"{internal} missing from EXCLUDE_SCRIPTS"
|
||||||
|
|||||||
@@ -22,6 +22,27 @@ on:
|
|||||||
branches: [main]
|
branches: [main]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
forge-parity-disabled:
|
||||||
|
name: forge_parity_disabled
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Assert forge_parity_disabled
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
# Build the dev-forge needle from char codes so this workflow
|
||||||
|
# file does not itself contain the forbidden literal (REQ-230).
|
||||||
|
needle="$(printf '\x67\x69\x74\x65\x61')"
|
||||||
|
if [ -d ".${needle}" ]; then
|
||||||
|
echo "forge_parity_disabled: dev-forge directory still present (D-232)" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if grep -rqi "$needle" .github/workflows/; then
|
||||||
|
echo "forge_parity_disabled: dev-forge references found in .github/workflows/ (D-232)" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "forge_parity_disabled: OK"
|
||||||
|
|
||||||
lint:
|
lint:
|
||||||
name: Lint
|
name: Lint
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
Reference in New Issue
Block a user