From 9a63c542deb228cf88c98e6ac53bc8d877f08f9b Mon Sep 17 00:00:00 2001 From: pelikhan Date: Tue, 13 Oct 2026 23:42:34 -0700 Subject: [PATCH] fix(metrics): bound full ecosystem snapshot persistence Couple the file, two-copy patch, and upload budgets; validate complete daily/latest snapshots before upload and push. Preserve backing-branch history with trusted validation phase metadata and bound uploaded daily history by its UTC filename. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/metrics-collector.lock.yml | 70 +++- .github/workflows/metrics-collector.md | 131 ++++++- actions/setup/js/memory_custom_validation.cjs | 2 + .../js/memory_custom_validation.test.cjs | 19 + .../js/metrics_collector_workflow.test.cjs | 355 ++++++++++++++++++ actions/setup/js/push_repo_memory.cjs | 2 + actions/setup/js/validate_memory_step.cjs | 2 + .../src/content/docs/reference/repo-memory.md | 4 +- 8 files changed, 563 insertions(+), 22 deletions(-) create mode 100644 actions/setup/js/metrics_collector_workflow.test.cjs diff --git a/.github/workflows/metrics-collector.lock.yml b/.github/workflows/metrics-collector.lock.yml index 4d3ee04358e..eee97204d3c 100644 --- a/.github/workflows/metrics-collector.lock.yml +++ b/.github/workflows/metrics-collector.lock.yml @@ -1,5 +1,5 @@ -# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"7abaebdbf5bcda3bcbd6c73be0eb380ab544249683290a806acd0f76fcb974bd","body_hash":"61188f5f2f828161a3a4253c346715afbd96fcd9ef24acce2432c4df770d7f1b","strict":true,"agent_id":"codex","agent_model":"copilot/gpt-6-luna","engine_versions":{"codex":"0.162.1"}} -# gh-aw-manifest: {"version":1,"secrets":["COPILOT_GITHUB_TOKEN","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GH_AW_OTEL_GRAFANA_AUTHORIZATION","GH_AW_OTEL_GRAFANA_ENDPOINT","GH_AW_OTEL_SENTRY_AUTHORIZATION","GH_AW_OTEL_SENTRY_ENDPOINT","GITHUB_TOKEN"],"actions":[{"repo":"actions/checkout","sha":"3d3c42e5aac5ba805825da76410c181273ba90b1","version":"v7.0.1"},{"repo":"actions/download-artifact","sha":"9000827ccba6bdab643e8b6fd33ac0654aef8333","version":"v8.0.2"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-go","sha":"b7ad1dad31e06c5925ef5d2fc7ad053ef454303e","version":"v7.0.0"},{"repo":"actions/setup-node","sha":"949feb2413d6458794dcd2491c4babbbce0c15c1","version":"v7.1.0"},{"repo":"actions/upload-artifact","sha":"cf430e030ddbb5b0abf93d22962f4752f3646cd9","version":"v7.0.2"},{"repo":"docker/build-push-action","sha":"c3c9e263c25d99ce0380d002d59b67737d91b0dc","version":"v7.4.0"},{"repo":"docker/setup-buildx-action","sha":"f87e5991a6d7451dcb8d9637bfbc97413f497069","version":"v4.4.1"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.28.57","digest":"sha256:a9861b2fad1d29c586bbbf98840a61056e30e2720d8d9ec2c0def9049566bd8a","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.28.57@sha256:a9861b2fad1d29c586bbbf98840a61056e30e2720d8d9ec2c0def9049566bd8a"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.28.57","digest":"sha256:f2925ff32dc972a5d8b90a7785b6ca68052efd2575fe896556ac457e22e86dcc","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.28.57@sha256:f2925ff32dc972a5d8b90a7785b6ca68052efd2575fe896556ac457e22e86dcc"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.28.57","digest":"sha256:be808c16b535ebabd070eebe086fe6bffd5bb86b2e5fe8e5cdad30068eb9c0ac","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.28.57@sha256:be808c16b535ebabd070eebe086fe6bffd5bb86b2e5fe8e5cdad30068eb9c0ac"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.4.31","digest":"sha256:dbec0f2ddef6adf83b7764019323096e3e5cc0f3886255f6320555b7fe2a7caf","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.4.31@sha256:dbec0f2ddef6adf83b7764019323096e3e5cc0f3886255f6320555b7fe2a7caf"},{"image":"ghcr.io/github/gh-aw-node","digest":"sha256:11c2c544876cdbaffefe0f7f1283b74838c209654b4934a78e23ed11f750490f","pinned_image":"ghcr.io/github/gh-aw-node@sha256:11c2c544876cdbaffefe0f7f1283b74838c209654b4934a78e23ed11f750490f"},{"image":"ghcr.io/github/github-mcp-server:v2.0.2","digest":"sha256:ffced0d76e77428532a2ced185516992d77fd146d69eec2f244e6237d1a97796","pinned_image":"ghcr.io/github/github-mcp-server:v2.0.2@sha256:ffced0d76e77428532a2ced185516992d77fd146d69eec2f244e6237d1a97796"}],"mcp_servers":[{"name":"agenticworkflows","tools":["*"]},{"name":"github","tools":["get_commit","get_file_contents","get_latest_release","get_me","get_pull_request","get_pull_request_comments","get_pull_request_diff","get_pull_request_files","get_pull_request_review_comments","get_pull_request_reviews","get_pull_request_status","get_release_by_tag","get_tag","issue_read","list_branches","list_commits","list_issue_types","list_issues","list_pull_requests","list_releases","list_starred_repositories","list_tags","pull_request_read","search_code","search_issues","search_pull_requests","search_repositories"]},{"name":"safeoutputs","tools":["create_issue","missing_data","missing_tool","noop","report_incomplete"]}],"threat_detection":{"mode":"enabled"}} +# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"fae813693c919f56805239a2846a2b2a5ca7362a09a467a1643288a21f3b39a5","body_hash":"f1598de07eed487786af6e99e9e30bdbf510b09a74526d27c2af991c3c8db0aa","strict":true,"agent_id":"codex","agent_model":"copilot/gpt-6-luna","engine_versions":{"codex":"0.162.1"}} +# gh-aw-manifest: {"version":1,"secrets":["COPILOT_GITHUB_TOKEN","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GH_AW_OTEL_GRAFANA_AUTHORIZATION","GH_AW_OTEL_GRAFANA_ENDPOINT","GH_AW_OTEL_SENTRY_AUTHORIZATION","GH_AW_OTEL_SENTRY_ENDPOINT","GITHUB_TOKEN"],"actions":[{"repo":"actions/checkout","sha":"3d3c42e5aac5ba805825da76410c181273ba90b1","version":"v7.0.1"},{"repo":"actions/download-artifact","sha":"9000827ccba6bdab643e8b6fd33ac0654aef8333","version":"v8.0.2"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-go","sha":"b7ad1dad31e06c5925ef5d2fc7ad053ef454303e","version":"v7.0.0"},{"repo":"actions/setup-node","sha":"949feb2413d6458794dcd2491c4babbbce0c15c1","version":"v7.1.0"},{"repo":"actions/upload-artifact","sha":"cf430e030ddbb5b0abf93d22962f4752f3646cd9","version":"v7.0.2"},{"repo":"docker/build-push-action","sha":"c3c9e263c25d99ce0380d002d59b67737d91b0dc","version":"v7.4.0"},{"repo":"docker/setup-buildx-action","sha":"f87e5991a6d7451dcb8d9637bfbc97413f497069","version":"v4.4.1"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.28.57","digest":"sha256:a9861b2fad1d29c586bbbf98840a61056e30e2720d8d9ec2c0def9049566bd8a","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.28.57@sha256:a9861b2fad1d29c586bbbf98840a61056e30e2720d8d9ec2c0def9049566bd8a"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.28.57","digest":"sha256:f2925ff32dc972a5d8b90a7785b6ca68052efd2575fe896556ac457e22e86dcc","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.28.57@sha256:f2925ff32dc972a5d8b90a7785b6ca68052efd2575fe896556ac457e22e86dcc"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.28.57","digest":"sha256:be808c16b535ebabd070eebe086fe6bffd5bb86b2e5fe8e5cdad30068eb9c0ac","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.28.57@sha256:be808c16b535ebabd070eebe086fe6bffd5bb86b2e5fe8e5cdad30068eb9c0ac"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.4.31","digest":"sha256:dbec0f2ddef6adf83b7764019323096e3e5cc0f3886255f6320555b7fe2a7caf","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.4.31@sha256:dbec0f2ddef6adf83b7764019323096e3e5cc0f3886255f6320555b7fe2a7caf"},{"image":"ghcr.io/github/gh-aw-node","digest":"sha256:11c2c544876cdbaffefe0f7f1283b74838c209654b4934a78e23ed11f750490f","pinned_image":"ghcr.io/github/gh-aw-node@sha256:11c2c544876cdbaffefe0f7f1283b74838c209654b4934a78e23ed11f750490f"},{"image":"ghcr.io/github/github-mcp-server:v2.0.2","digest":"sha256:ffced0d76e77428532a2ced185516992d77fd146d69eec2f244e6237d1a97796","pinned_image":"ghcr.io/github/github-mcp-server:v2.0.2@sha256:ffced0d76e77428532a2ced185516992d77fd146d69eec2f244e6237d1a97796"}],"memory_validation_scripts":[{"memory":"repo-memory:default","sha256":"7f0b7ce63037fdf31ef02491a4fac4e08a50eca59d5366e6c10bff4d120ab6f2"}],"mcp_servers":[{"name":"agenticworkflows","tools":["*"]},{"name":"github","tools":["get_commit","get_file_contents","get_latest_release","get_me","get_pull_request","get_pull_request_comments","get_pull_request_diff","get_pull_request_files","get_pull_request_review_comments","get_pull_request_reviews","get_pull_request_status","get_release_by_tag","get_tag","issue_read","list_branches","list_commits","list_issue_types","list_issues","list_pull_requests","list_releases","list_starred_repositories","list_tags","pull_request_read","search_code","search_issues","search_pull_requests","search_repositories"]},{"name":"safeoutputs","tools":["create_issue","missing_data","missing_tool","noop","report_incomplete"]}],"threat_detection":{"mode":"enabled"}} # This file was automatically generated by gh-aw. DO NOT EDIT. To debug this workflow, load the skill at https://github.com/github/gh-aw/blob/main/debug.md # # ___ _ _ @@ -335,7 +335,7 @@ jobs: GH_AW_GITHUB_WORKSPACE: ${{ github.workspace }} GH_AW_MCP_CLI_SERVERS_LIST: "- `agenticworkflows` — run `agenticworkflows --help` to see available tools\n- `safeoutputs` — run `safeoutputs --help` to see available tools" GH_AW_MEMORY_BRANCH_NAME: 'memory/meta-orchestrators' - GH_AW_MEMORY_CONSTRAINTS: "\n\n**Constraints:**\n- **Allowed Files**: Only files matching patterns: metrics/**\n- **Max File Size**: 102400 bytes (0.10 MB) per file\n- **Max File Count**: 100 files per commit\n- **Max Patch Size**: 131072 bytes (128 KB) total per push (max: 1024 KB)\n" + GH_AW_MEMORY_CONSTRAINTS: "\n\n**Constraints:**\n- **Allowed Files**: Only files matching patterns: metrics/**\n- **Max File Size**: 524288 bytes (0.50 MB) per file\n- **Max File Count**: 100 files per commit\n- **Max Patch Size**: 1048576 bytes (1024 KB) total per push (max: 1024 KB)\n" GH_AW_MEMORY_DESCRIPTION: '' GH_AW_MEMORY_DIR: '/tmp/gh-aw/repo-memory/default/' GH_AW_MEMORY_TARGET_REPO: ' of the current repository' @@ -583,6 +583,20 @@ jobs: MEMORY_DIR: /tmp/gh-aw/repo-memory/default CREATE_ORPHAN: true run: bash "${RUNNER_TEMP}/gh-aw/actions/clone_repo_memory_branch.sh" + - name: Validate repo-memory baseline (default) + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + MEMORY_DIR: /tmp/gh-aw/repo-memory/default + MEMORY_ID: default + VALIDATION_SCRIPT_B64: Y29uc3Qgcm9vdCA9IHBhdGguam9pbihtZW1vcnlSb290LCAibWV0cmljcyIpOwpjb25zdCBsYXRlc3QgPSBmcy5yZWFkRmlsZVN5bmMocGF0aC5qb2luKHJvb3QsICJsYXRlc3QuanNvbiIpKTsKY29uc3Qgc25hcHNob3QgPSBKU09OLnBhcnNlKGxhdGVzdCk7CmNvbnN0IHRpbWVzdGFtcCA9IG5ldyBEYXRlKHNuYXBzaG90LnRpbWVzdGFtcCk7CmlmICghTnVtYmVyLmlzRmluaXRlKHRpbWVzdGFtcC5nZXRUaW1lKCkpIHx8CiAgICBEYXRlLm5vdygpIC0gdGltZXN0YW1wLmdldFRpbWUoKSA+IDg2NDAwMDAwIHx8CiAgICB0aW1lc3RhbXAuZ2V0VGltZSgpIC0gRGF0ZS5ub3coKSA+IDM2MDAwMDApIHsKICB0aHJvdyBuZXcgRXJyb3IoIkxhdGVzdCBtZXRyaWNzIG11c3QgaGF2ZSBhIHRpbWVzdGFtcCBpbiB0aGUgY3VycmVudCBjb2xsZWN0aW9uIHdpbmRvdy4iKTsKfQppZiAoIXNuYXBzaG90LndvcmtmbG93cyB8fCBBcnJheS5pc0FycmF5KHNuYXBzaG90LndvcmtmbG93cykgfHwKICAgIHR5cGVvZiBzbmFwc2hvdC53b3JrZmxvd3MgIT09ICJvYmplY3QiIHx8IE9iamVjdC5rZXlzKHNuYXBzaG90LndvcmtmbG93cykubGVuZ3RoID09PSAwKSB7CiAgdGhyb3cgbmV3IEVycm9yKCJMYXRlc3QgbWV0cmljcyBtdXN0IGNvbnRhaW4gd29ya2Zsb3cgcmVjb3Jkcy4iKTsKfQpjb25zdCBkYXkgPSB0aW1lc3RhbXAudG9JU09TdHJpbmcoKS5zbGljZSgwLCAxMCk7CmNvbnN0IGRhaWx5UGF0aCA9IHBhdGguam9pbihyb290LCAiZGFpbHkiLCBgJHtkYXl9Lmpzb25gKTsKaWYgKCFmcy5leGlzdHNTeW5jKGRhaWx5UGF0aCkgfHwgIWZzLnJlYWRGaWxlU3luYyhkYWlseVBhdGgpLmVxdWFscyhsYXRlc3QpKSB7CiAgdGhyb3cgbmV3IEVycm9yKCJMYXRlc3QgbWV0cmljcyBtdXN0IGV4YWN0bHkgbWF0Y2ggdGhlIGRhaWx5IHNuYXBzaG90IGZvciBpdHMgVVRDIHRpbWVzdGFtcC4iKTsKfQpjb25zdCBtYXhGaWxlQnl0ZXMgPSA1MjQyODg7CmNvbnN0IG1heFBhdGNoQnl0ZXMgPSAxMDQ4NTc2Owpjb25zdCBtYXhBcnRpZmFjdEJ5dGVzID0gMTY3NzcyMTY7CmNvbnN0IG1heEFydGlmYWN0RmlsZXMgPSAxMDA7Ci8vIEEgZmlyc3QgY29sbGVjdGlvbiBhZGRzIGJvdGggY29waWVzOyBnaXQgY291bnRzIHRoZSAnKycgcHJlZml4IG9uIGVhY2ggbGluZS4KY29uc3QgYWRkZWRCeXRlcyA9IGxhdGVzdC5sZW5ndGggKyBsYXRlc3QudG9TdHJpbmcoInV0ZjgiKS5zcGxpdCgiXG4iKS5sZW5ndGg7CmlmIChsYXRlc3QubGVuZ3RoID4gbWF4RmlsZUJ5dGVzIHx8IDIgKiBhZGRlZEJ5dGVzID4gbWF4UGF0Y2hCeXRlcykgewogIHRocm93IG5ldyBFcnJvcigiQ29tcGxldGUgbWV0cmljcyBzbmFwc2hvdCBleGNlZWRzIHRoZSBmaWxlIG9yIHR3by1jb3B5IHBhdGNoIGJ1ZGdldDsgZG8gbm90IHRydW5jYXRlIHdvcmtmbG93cyBvciBmaWVsZHMuIik7Cn0KLy8gUHVzaCB2YWxpZGF0aW9uIHNlZXMgaGlzdG9yaWNhbCBiYWNraW5nLWJyYW5jaCBmaWxlcyBvbWl0dGVkIGZyb20gdGhlIHVwbG9hZC4KaWYgKG1lbW9yeVBoYXNlID09PSAicHVzaCIpIHJldHVybjsKY29uc3QgY3V0b2ZmID0gbmV3IERhdGUoYCR7ZGF5fVQwMDowMDowMFpgKTsKY3V0b2ZmLnNldFVUQ0RhdGUoY3V0b2ZmLmdldFVUQ0RhdGUoKSAtIDI5KTsKY29uc3Qgb2xkZXN0RGF5ID0gY3V0b2ZmLnRvSVNPU3RyaW5nKCkuc2xpY2UoMCwgMTApOwpmb3IgKGNvbnN0IG5hbWUgb2YgZnMucmVhZGRpclN5bmMocGF0aC5qb2luKHJvb3QsICJkYWlseSIpKSkgewogIGlmICghL15cZHs0fS1cZHsyfS1cZHsyfVwuanNvbiQvaS50ZXN0KG5hbWUpKSBjb250aW51ZTsKICBpZiAoIW5hbWUuZW5kc1dpdGgoIi5qc29uIikpIHRocm93IG5ldyBFcnJvcigiRGF0ZWQgZGFpbHkgbWV0cmljcyBtdXN0IHVzZSBsb3dlcmNhc2UgLmpzb24gZmlsZW5hbWVzLiIpOwogIGNvbnN0IGRhdGUgPSBuYW1lLnNsaWNlKDAsIDEwKTsKICBjb25zdCBwYXJzZWQgPSBuZXcgRGF0ZShgJHtkYXRlfVQwMDowMDowMFpgKTsKICBpZiAoIU51bWJlci5pc0Zpbml0ZShwYXJzZWQuZ2V0VGltZSgpKSB8fCBwYXJzZWQudG9JU09TdHJpbmcoKS5zbGljZSgwLCAxMCkgIT09IGRhdGUpIHsKICAgIHRocm93IG5ldyBFcnJvcigiRGF0ZWQgZGFpbHkgbWV0cmljcyBtdXN0IHVzZSB2YWxpZCBjYWxlbmRhciBkYXRlcy4iKTsKICB9CiAgaWYgKGRhdGUgPCBvbGRlc3REYXkgfHwgZGF0ZSA+IGRheSkgewogICAgdGhyb3cgbmV3IEVycm9yKCJQcnVuZSBkYXRlZCBkYWlseSBtZXRyaWNzIG91dHNpZGUgdGhlIDMwLWRheSB1cGxvYWQgd2luZG93IGJlZm9yZSBwZXJzaXN0ZW5jZS4iKTsKICB9Cn0KbGV0IGFydGlmYWN0Qnl0ZXMgPSAwOwpsZXQgYXJ0aWZhY3RGaWxlcyA9IDA7CmZ1bmN0aW9uIGNoZWNrRGlyZWN0b3J5KGRpcmVjdG9yeSkgewogIGZvciAoY29uc3QgZW50cnkgb2YgZnMucmVhZGRpclN5bmMoZGlyZWN0b3J5LCB7IHdpdGhGaWxlVHlwZXM6IHRydWUgfSkpIHsKICAgIGNvbnN0IGZpbGVuYW1lID0gcGF0aC5qb2luKGRpcmVjdG9yeSwgZW50cnkubmFtZSk7CiAgICBpZiAoZW50cnkuaXNEaXJlY3RvcnkoKSkgewogICAgICBjaGVja0RpcmVjdG9yeShmaWxlbmFtZSk7CiAgICB9IGVsc2UgaWYgKHBhdGguZXh0bmFtZShlbnRyeS5uYW1lKS50b0xvd2VyQ2FzZSgpID09PSAiLmpzb24iKSB7CiAgICAgIGNvbnN0IGJ5dGVzID0gZnMuc3RhdFN5bmMoZmlsZW5hbWUpLnNpemU7CiAgICAgIGlmIChieXRlcyA+IG1heEZpbGVCeXRlcykgdGhyb3cgbmV3IEVycm9yKCJSZXRhaW5lZCBtZXRyaWNzIEpTT04gZXhjZWVkcyB0aGUgcGVyLWZpbGUgYnVkZ2V0LiIpOwogICAgICBhcnRpZmFjdEJ5dGVzICs9IGJ5dGVzOwogICAgICBhcnRpZmFjdEZpbGVzKys7CiAgICB9CiAgfQp9CmNoZWNrRGlyZWN0b3J5KHJvb3QpOwppZiAoYXJ0aWZhY3RCeXRlcyA+IG1heEFydGlmYWN0Qnl0ZXMgfHwgYXJ0aWZhY3RGaWxlcyA+IG1heEFydGlmYWN0RmlsZXMpIHsKICB0aHJvdyBuZXcgRXJyb3IoIk1ldHJpY3MgdXBsb2FkIGV4Y2VlZHMgdGhlIDE2TWlCIG9yIDEwMC1maWxlIGFydGlmYWN0IGJ1ZGdldC4iKTsKfQo= + VALIDATION_SCRIPT_REQUIRED: 'true' + VALIDATION_TIMEOUT_SECONDS: 60 + with: + script: | + const path = require('path'); + const actionsDir = path.join(process.env.RUNNER_TEMP, 'gh-aw', 'actions'); + const { validateRepoMemoryBaseline } = require(path.join(actionsDir, 'validate_memory_step.cjs')); + validateRepoMemoryBaseline(core); - name: Configure Git credentials env: GITHUB_REPOSITORY: ${{ github.repository }} @@ -675,7 +689,7 @@ jobs: env: GH_AW_FILE_ROOT: "${{ runner.temp }}/gh-aw" GH_AW_FILE_CONFIG: "{\"files\":[{\"path\":\"safeoutputs/config.json\",\"content_env\":\"GH_AW_SAFE_OUTPUTS_CONFIG\"}]}" - GH_AW_SAFE_OUTPUTS_CONFIG: "{\"create_issue\":{\"labels\":[\"metrics-collector\"],\"max\":1,\"title_prefix\":\"[metrics-collector]\"},\"create_report_incomplete_issue\":{},\"missing_data\":{},\"missing_tool\":{},\"noop\":{\"max\":2,\"report-as-issue\":\"true\"},\"push_repo_memory\":{\"memories\":[{\"dir\":\"/tmp/gh-aw/repo-memory/default\",\"file_glob\":\"metrics/**\",\"id\":\"default\",\"max_file_count\":100,\"max_file_size\":102400,\"max_patch_size\":131072}]},\"report_incomplete\":{}}" + GH_AW_SAFE_OUTPUTS_CONFIG: "{\"create_issue\":{\"labels\":[\"metrics-collector\"],\"max\":1,\"title_prefix\":\"[metrics-collector]\"},\"create_report_incomplete_issue\":{},\"missing_data\":{},\"missing_tool\":{},\"noop\":{\"max\":2,\"report-as-issue\":\"true\"},\"push_repo_memory\":{\"memories\":[{\"allowed_extensions\":[\".json\"],\"dir\":\"/tmp/gh-aw/repo-memory/default\",\"file_glob\":\"metrics/**\",\"id\":\"default\",\"max_file_count\":100,\"max_file_size\":524288,\"max_patch_size\":1048576,\"validation\":{\"script\":\"const root = path.join(memoryRoot, \\\"metrics\\\");\\nconst latest = fs.readFileSync(path.join(root, \\\"latest.json\\\"));\\nconst snapshot = JSON.parse(latest);\\nconst timestamp = new Date(snapshot.timestamp);\\nif (!Number.isFinite(timestamp.getTime()) ||\\n Date.now() - timestamp.getTime() > 86400000 ||\\n timestamp.getTime() - Date.now() > 3600000) {\\n throw new Error(\\\"Latest metrics must have a timestamp in the current collection window.\\\");\\n}\\nif (!snapshot.workflows || Array.isArray(snapshot.workflows) ||\\n typeof snapshot.workflows !== \\\"object\\\" || Object.keys(snapshot.workflows).length === 0) {\\n throw new Error(\\\"Latest metrics must contain workflow records.\\\");\\n}\\nconst day = timestamp.toISOString().slice(0, 10);\\nconst dailyPath = path.join(root, \\\"daily\\\", `${day}.json`);\\nif (!fs.existsSync(dailyPath) || !fs.readFileSync(dailyPath).equals(latest)) {\\n throw new Error(\\\"Latest metrics must exactly match the daily snapshot for its UTC timestamp.\\\");\\n}\\nconst maxFileBytes = 524288;\\nconst maxPatchBytes = 1048576;\\nconst maxArtifactBytes = 16777216;\\nconst maxArtifactFiles = 100;\\n// A first collection adds both copies; git counts the '+' prefix on each line.\\nconst addedBytes = latest.length + latest.toString(\\\"utf8\\\").split(\\\"\\\\n\\\").length;\\nif (latest.length > maxFileBytes || 2 * addedBytes > maxPatchBytes) {\\n throw new Error(\\\"Complete metrics snapshot exceeds the file or two-copy patch budget; do not truncate workflows or fields.\\\");\\n}\\n// Push validation sees historical backing-branch files omitted from the upload.\\nif (memoryPhase === \\\"push\\\") return;\\nconst cutoff = new Date(`${day}T00:00:00Z`);\\ncutoff.setUTCDate(cutoff.getUTCDate() - 29);\\nconst oldestDay = cutoff.toISOString().slice(0, 10);\\nfor (const name of fs.readdirSync(path.join(root, \\\"daily\\\"))) {\\n if (!/^\\\\d{4}-\\\\d{2}-\\\\d{2}\\\\.json$/i.test(name)) continue;\\n if (!name.endsWith(\\\".json\\\")) throw new Error(\\\"Dated daily metrics must use lowercase .json filenames.\\\");\\n const date = name.slice(0, 10);\\n const parsed = new Date(`${date}T00:00:00Z`);\\n if (!Number.isFinite(parsed.getTime()) || parsed.toISOString().slice(0, 10) !== date) {\\n throw new Error(\\\"Dated daily metrics must use valid calendar dates.\\\");\\n }\\n if (date < oldestDay || date > day) {\\n throw new Error(\\\"Prune dated daily metrics outside the 30-day upload window before persistence.\\\");\\n }\\n}\\nlet artifactBytes = 0;\\nlet artifactFiles = 0;\\nfunction checkDirectory(directory) {\\n for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {\\n const filename = path.join(directory, entry.name);\\n if (entry.isDirectory()) {\\n checkDirectory(filename);\\n } else if (path.extname(entry.name).toLowerCase() === \\\".json\\\") {\\n const bytes = fs.statSync(filename).size;\\n if (bytes > maxFileBytes) throw new Error(\\\"Retained metrics JSON exceeds the per-file budget.\\\");\\n artifactBytes += bytes;\\n artifactFiles++;\\n }\\n }\\n}\\ncheckDirectory(root);\\nif (artifactBytes > maxArtifactBytes || artifactFiles > maxArtifactFiles) {\\n throw new Error(\\\"Metrics upload exceeds the 16MiB or 100-file artifact budget.\\\");\\n}\\n\",\"script_required\":true,\"timeout\":60}}]},\"report_incomplete\":{}}" with: script: | const path = require('path'); @@ -1221,19 +1235,32 @@ jobs: echo '{"items":[]}' > /tmp/gh-aw/agent_output.json fi # Upload repo memory as artifacts for push job - - name: Sanitize repo-memory filenames (default) + - name: Check repo-memory baseline (default) + id: check_repo_memory_baseline_64656661756c74 if: always() + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + MEMORY_DIR: /tmp/gh-aw/repo-memory/default + MEMORY_ID: default + with: + script: | + const path = require('path'); + const actionsDir = path.join(process.env.RUNNER_TEMP, 'gh-aw', 'actions'); + const { checkRepoMemoryBaseline } = require(path.join(actionsDir, 'validate_memory_step.cjs')); + checkRepoMemoryBaseline(core); + - name: Sanitize repo-memory filenames (default) + if: always() && steps.check_repo_memory_baseline_64656661756c74.outputs.skip != 'true' continue-on-error: true env: MEMORY_DIR: /tmp/gh-aw/repo-memory/default run: bash "${RUNNER_TEMP}/gh-aw/actions/sanitize_repo_memory_filenames.sh" - name: Filter repo-memory files (default) id: filter_repo_memory_64656661756c74 - if: always() + if: always() && steps.check_repo_memory_baseline_64656661756c74.outputs.skip != 'true' uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: MEMORY_DIR: /tmp/gh-aw/repo-memory/default - ALLOWED_EXTENSIONS: '[]' + ALLOWED_EXTENSIONS: '[".json"]' FILE_GLOB_FILTER: "metrics/**" with: script: | @@ -1243,8 +1270,26 @@ jobs: setupGlobals(core, github, context, exec, io, getOctokit); const { main } = require(path.join(actionsDir, 'memory_file_eligibility.cjs')); await main(); + - name: Validate repo-memory domain content (default) + id: validate_repo_memory_64656661756c74 + if: always() && steps.check_repo_memory_baseline_64656661756c74.outputs.skip != 'true' && steps.filter_repo_memory_64656661756c74.outcome == 'success' + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + MEMORY_DIR: /tmp/gh-aw/repo-memory/default + MEMORY_ID: default + VALIDATION_SCRIPT_B64: Y29uc3Qgcm9vdCA9IHBhdGguam9pbihtZW1vcnlSb290LCAibWV0cmljcyIpOwpjb25zdCBsYXRlc3QgPSBmcy5yZWFkRmlsZVN5bmMocGF0aC5qb2luKHJvb3QsICJsYXRlc3QuanNvbiIpKTsKY29uc3Qgc25hcHNob3QgPSBKU09OLnBhcnNlKGxhdGVzdCk7CmNvbnN0IHRpbWVzdGFtcCA9IG5ldyBEYXRlKHNuYXBzaG90LnRpbWVzdGFtcCk7CmlmICghTnVtYmVyLmlzRmluaXRlKHRpbWVzdGFtcC5nZXRUaW1lKCkpIHx8CiAgICBEYXRlLm5vdygpIC0gdGltZXN0YW1wLmdldFRpbWUoKSA+IDg2NDAwMDAwIHx8CiAgICB0aW1lc3RhbXAuZ2V0VGltZSgpIC0gRGF0ZS5ub3coKSA+IDM2MDAwMDApIHsKICB0aHJvdyBuZXcgRXJyb3IoIkxhdGVzdCBtZXRyaWNzIG11c3QgaGF2ZSBhIHRpbWVzdGFtcCBpbiB0aGUgY3VycmVudCBjb2xsZWN0aW9uIHdpbmRvdy4iKTsKfQppZiAoIXNuYXBzaG90LndvcmtmbG93cyB8fCBBcnJheS5pc0FycmF5KHNuYXBzaG90LndvcmtmbG93cykgfHwKICAgIHR5cGVvZiBzbmFwc2hvdC53b3JrZmxvd3MgIT09ICJvYmplY3QiIHx8IE9iamVjdC5rZXlzKHNuYXBzaG90LndvcmtmbG93cykubGVuZ3RoID09PSAwKSB7CiAgdGhyb3cgbmV3IEVycm9yKCJMYXRlc3QgbWV0cmljcyBtdXN0IGNvbnRhaW4gd29ya2Zsb3cgcmVjb3Jkcy4iKTsKfQpjb25zdCBkYXkgPSB0aW1lc3RhbXAudG9JU09TdHJpbmcoKS5zbGljZSgwLCAxMCk7CmNvbnN0IGRhaWx5UGF0aCA9IHBhdGguam9pbihyb290LCAiZGFpbHkiLCBgJHtkYXl9Lmpzb25gKTsKaWYgKCFmcy5leGlzdHNTeW5jKGRhaWx5UGF0aCkgfHwgIWZzLnJlYWRGaWxlU3luYyhkYWlseVBhdGgpLmVxdWFscyhsYXRlc3QpKSB7CiAgdGhyb3cgbmV3IEVycm9yKCJMYXRlc3QgbWV0cmljcyBtdXN0IGV4YWN0bHkgbWF0Y2ggdGhlIGRhaWx5IHNuYXBzaG90IGZvciBpdHMgVVRDIHRpbWVzdGFtcC4iKTsKfQpjb25zdCBtYXhGaWxlQnl0ZXMgPSA1MjQyODg7CmNvbnN0IG1heFBhdGNoQnl0ZXMgPSAxMDQ4NTc2Owpjb25zdCBtYXhBcnRpZmFjdEJ5dGVzID0gMTY3NzcyMTY7CmNvbnN0IG1heEFydGlmYWN0RmlsZXMgPSAxMDA7Ci8vIEEgZmlyc3QgY29sbGVjdGlvbiBhZGRzIGJvdGggY29waWVzOyBnaXQgY291bnRzIHRoZSAnKycgcHJlZml4IG9uIGVhY2ggbGluZS4KY29uc3QgYWRkZWRCeXRlcyA9IGxhdGVzdC5sZW5ndGggKyBsYXRlc3QudG9TdHJpbmcoInV0ZjgiKS5zcGxpdCgiXG4iKS5sZW5ndGg7CmlmIChsYXRlc3QubGVuZ3RoID4gbWF4RmlsZUJ5dGVzIHx8IDIgKiBhZGRlZEJ5dGVzID4gbWF4UGF0Y2hCeXRlcykgewogIHRocm93IG5ldyBFcnJvcigiQ29tcGxldGUgbWV0cmljcyBzbmFwc2hvdCBleGNlZWRzIHRoZSBmaWxlIG9yIHR3by1jb3B5IHBhdGNoIGJ1ZGdldDsgZG8gbm90IHRydW5jYXRlIHdvcmtmbG93cyBvciBmaWVsZHMuIik7Cn0KLy8gUHVzaCB2YWxpZGF0aW9uIHNlZXMgaGlzdG9yaWNhbCBiYWNraW5nLWJyYW5jaCBmaWxlcyBvbWl0dGVkIGZyb20gdGhlIHVwbG9hZC4KaWYgKG1lbW9yeVBoYXNlID09PSAicHVzaCIpIHJldHVybjsKY29uc3QgY3V0b2ZmID0gbmV3IERhdGUoYCR7ZGF5fVQwMDowMDowMFpgKTsKY3V0b2ZmLnNldFVUQ0RhdGUoY3V0b2ZmLmdldFVUQ0RhdGUoKSAtIDI5KTsKY29uc3Qgb2xkZXN0RGF5ID0gY3V0b2ZmLnRvSVNPU3RyaW5nKCkuc2xpY2UoMCwgMTApOwpmb3IgKGNvbnN0IG5hbWUgb2YgZnMucmVhZGRpclN5bmMocGF0aC5qb2luKHJvb3QsICJkYWlseSIpKSkgewogIGlmICghL15cZHs0fS1cZHsyfS1cZHsyfVwuanNvbiQvaS50ZXN0KG5hbWUpKSBjb250aW51ZTsKICBpZiAoIW5hbWUuZW5kc1dpdGgoIi5qc29uIikpIHRocm93IG5ldyBFcnJvcigiRGF0ZWQgZGFpbHkgbWV0cmljcyBtdXN0IHVzZSBsb3dlcmNhc2UgLmpzb24gZmlsZW5hbWVzLiIpOwogIGNvbnN0IGRhdGUgPSBuYW1lLnNsaWNlKDAsIDEwKTsKICBjb25zdCBwYXJzZWQgPSBuZXcgRGF0ZShgJHtkYXRlfVQwMDowMDowMFpgKTsKICBpZiAoIU51bWJlci5pc0Zpbml0ZShwYXJzZWQuZ2V0VGltZSgpKSB8fCBwYXJzZWQudG9JU09TdHJpbmcoKS5zbGljZSgwLCAxMCkgIT09IGRhdGUpIHsKICAgIHRocm93IG5ldyBFcnJvcigiRGF0ZWQgZGFpbHkgbWV0cmljcyBtdXN0IHVzZSB2YWxpZCBjYWxlbmRhciBkYXRlcy4iKTsKICB9CiAgaWYgKGRhdGUgPCBvbGRlc3REYXkgfHwgZGF0ZSA+IGRheSkgewogICAgdGhyb3cgbmV3IEVycm9yKCJQcnVuZSBkYXRlZCBkYWlseSBtZXRyaWNzIG91dHNpZGUgdGhlIDMwLWRheSB1cGxvYWQgd2luZG93IGJlZm9yZSBwZXJzaXN0ZW5jZS4iKTsKICB9Cn0KbGV0IGFydGlmYWN0Qnl0ZXMgPSAwOwpsZXQgYXJ0aWZhY3RGaWxlcyA9IDA7CmZ1bmN0aW9uIGNoZWNrRGlyZWN0b3J5KGRpcmVjdG9yeSkgewogIGZvciAoY29uc3QgZW50cnkgb2YgZnMucmVhZGRpclN5bmMoZGlyZWN0b3J5LCB7IHdpdGhGaWxlVHlwZXM6IHRydWUgfSkpIHsKICAgIGNvbnN0IGZpbGVuYW1lID0gcGF0aC5qb2luKGRpcmVjdG9yeSwgZW50cnkubmFtZSk7CiAgICBpZiAoZW50cnkuaXNEaXJlY3RvcnkoKSkgewogICAgICBjaGVja0RpcmVjdG9yeShmaWxlbmFtZSk7CiAgICB9IGVsc2UgaWYgKHBhdGguZXh0bmFtZShlbnRyeS5uYW1lKS50b0xvd2VyQ2FzZSgpID09PSAiLmpzb24iKSB7CiAgICAgIGNvbnN0IGJ5dGVzID0gZnMuc3RhdFN5bmMoZmlsZW5hbWUpLnNpemU7CiAgICAgIGlmIChieXRlcyA+IG1heEZpbGVCeXRlcykgdGhyb3cgbmV3IEVycm9yKCJSZXRhaW5lZCBtZXRyaWNzIEpTT04gZXhjZWVkcyB0aGUgcGVyLWZpbGUgYnVkZ2V0LiIpOwogICAgICBhcnRpZmFjdEJ5dGVzICs9IGJ5dGVzOwogICAgICBhcnRpZmFjdEZpbGVzKys7CiAgICB9CiAgfQp9CmNoZWNrRGlyZWN0b3J5KHJvb3QpOwppZiAoYXJ0aWZhY3RCeXRlcyA+IG1heEFydGlmYWN0Qnl0ZXMgfHwgYXJ0aWZhY3RGaWxlcyA+IG1heEFydGlmYWN0RmlsZXMpIHsKICB0aHJvdyBuZXcgRXJyb3IoIk1ldHJpY3MgdXBsb2FkIGV4Y2VlZHMgdGhlIDE2TWlCIG9yIDEwMC1maWxlIGFydGlmYWN0IGJ1ZGdldC4iKTsKfQo= + VALIDATION_SCRIPT_REQUIRED: 'true' + VALIDATION_TIMEOUT_SECONDS: 60 + with: + script: | + const path = require('path'); + const actionsDir = path.join(process.env.RUNNER_TEMP, 'gh-aw', 'actions'); + const { setupGlobals } = require(path.join(actionsDir, 'setup_globals.cjs')); + setupGlobals(core, github, context, exec, io, getOctokit); + const { validateMemoryStep } = require(path.join(actionsDir, 'validate_memory_step.cjs')); + validateMemoryStep(core, { kind: 'repo', formatJSON: process.env.FORMAT_JSON === 'true', requireValidationScript: process.env.VALIDATION_SCRIPT_REQUIRED === 'true' }); - name: Upload repo-memory artifact (default) - if: always() && steps.filter_repo_memory_64656661756c74.outcome == 'success' + if: always() && steps.check_repo_memory_baseline_64656661756c74.outputs.skip != 'true' && steps.filter_repo_memory_64656661756c74.outcome == 'success' && steps.validate_repo_memory_64656661756c74.outcome == 'success' uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: repo-memory-default @@ -2022,11 +2067,14 @@ jobs: MEMORY_ID: default TARGET_REPO: ${{ github.repository }} BRANCH_NAME: memory/meta-orchestrators - MAX_FILE_SIZE: 102400 + MAX_FILE_SIZE: 524288 MAX_FILE_COUNT: 100 - MAX_PATCH_SIZE: 131072 - ALLOWED_EXTENSIONS: '[]' + MAX_PATCH_SIZE: 1048576 + ALLOWED_EXTENSIONS: '[".json"]' FILE_GLOB_FILTER: "metrics/**" + VALIDATION_SCRIPT_B64: Y29uc3Qgcm9vdCA9IHBhdGguam9pbihtZW1vcnlSb290LCAibWV0cmljcyIpOwpjb25zdCBsYXRlc3QgPSBmcy5yZWFkRmlsZVN5bmMocGF0aC5qb2luKHJvb3QsICJsYXRlc3QuanNvbiIpKTsKY29uc3Qgc25hcHNob3QgPSBKU09OLnBhcnNlKGxhdGVzdCk7CmNvbnN0IHRpbWVzdGFtcCA9IG5ldyBEYXRlKHNuYXBzaG90LnRpbWVzdGFtcCk7CmlmICghTnVtYmVyLmlzRmluaXRlKHRpbWVzdGFtcC5nZXRUaW1lKCkpIHx8CiAgICBEYXRlLm5vdygpIC0gdGltZXN0YW1wLmdldFRpbWUoKSA+IDg2NDAwMDAwIHx8CiAgICB0aW1lc3RhbXAuZ2V0VGltZSgpIC0gRGF0ZS5ub3coKSA+IDM2MDAwMDApIHsKICB0aHJvdyBuZXcgRXJyb3IoIkxhdGVzdCBtZXRyaWNzIG11c3QgaGF2ZSBhIHRpbWVzdGFtcCBpbiB0aGUgY3VycmVudCBjb2xsZWN0aW9uIHdpbmRvdy4iKTsKfQppZiAoIXNuYXBzaG90LndvcmtmbG93cyB8fCBBcnJheS5pc0FycmF5KHNuYXBzaG90LndvcmtmbG93cykgfHwKICAgIHR5cGVvZiBzbmFwc2hvdC53b3JrZmxvd3MgIT09ICJvYmplY3QiIHx8IE9iamVjdC5rZXlzKHNuYXBzaG90LndvcmtmbG93cykubGVuZ3RoID09PSAwKSB7CiAgdGhyb3cgbmV3IEVycm9yKCJMYXRlc3QgbWV0cmljcyBtdXN0IGNvbnRhaW4gd29ya2Zsb3cgcmVjb3Jkcy4iKTsKfQpjb25zdCBkYXkgPSB0aW1lc3RhbXAudG9JU09TdHJpbmcoKS5zbGljZSgwLCAxMCk7CmNvbnN0IGRhaWx5UGF0aCA9IHBhdGguam9pbihyb290LCAiZGFpbHkiLCBgJHtkYXl9Lmpzb25gKTsKaWYgKCFmcy5leGlzdHNTeW5jKGRhaWx5UGF0aCkgfHwgIWZzLnJlYWRGaWxlU3luYyhkYWlseVBhdGgpLmVxdWFscyhsYXRlc3QpKSB7CiAgdGhyb3cgbmV3IEVycm9yKCJMYXRlc3QgbWV0cmljcyBtdXN0IGV4YWN0bHkgbWF0Y2ggdGhlIGRhaWx5IHNuYXBzaG90IGZvciBpdHMgVVRDIHRpbWVzdGFtcC4iKTsKfQpjb25zdCBtYXhGaWxlQnl0ZXMgPSA1MjQyODg7CmNvbnN0IG1heFBhdGNoQnl0ZXMgPSAxMDQ4NTc2Owpjb25zdCBtYXhBcnRpZmFjdEJ5dGVzID0gMTY3NzcyMTY7CmNvbnN0IG1heEFydGlmYWN0RmlsZXMgPSAxMDA7Ci8vIEEgZmlyc3QgY29sbGVjdGlvbiBhZGRzIGJvdGggY29waWVzOyBnaXQgY291bnRzIHRoZSAnKycgcHJlZml4IG9uIGVhY2ggbGluZS4KY29uc3QgYWRkZWRCeXRlcyA9IGxhdGVzdC5sZW5ndGggKyBsYXRlc3QudG9TdHJpbmcoInV0ZjgiKS5zcGxpdCgiXG4iKS5sZW5ndGg7CmlmIChsYXRlc3QubGVuZ3RoID4gbWF4RmlsZUJ5dGVzIHx8IDIgKiBhZGRlZEJ5dGVzID4gbWF4UGF0Y2hCeXRlcykgewogIHRocm93IG5ldyBFcnJvcigiQ29tcGxldGUgbWV0cmljcyBzbmFwc2hvdCBleGNlZWRzIHRoZSBmaWxlIG9yIHR3by1jb3B5IHBhdGNoIGJ1ZGdldDsgZG8gbm90IHRydW5jYXRlIHdvcmtmbG93cyBvciBmaWVsZHMuIik7Cn0KLy8gUHVzaCB2YWxpZGF0aW9uIHNlZXMgaGlzdG9yaWNhbCBiYWNraW5nLWJyYW5jaCBmaWxlcyBvbWl0dGVkIGZyb20gdGhlIHVwbG9hZC4KaWYgKG1lbW9yeVBoYXNlID09PSAicHVzaCIpIHJldHVybjsKY29uc3QgY3V0b2ZmID0gbmV3IERhdGUoYCR7ZGF5fVQwMDowMDowMFpgKTsKY3V0b2ZmLnNldFVUQ0RhdGUoY3V0b2ZmLmdldFVUQ0RhdGUoKSAtIDI5KTsKY29uc3Qgb2xkZXN0RGF5ID0gY3V0b2ZmLnRvSVNPU3RyaW5nKCkuc2xpY2UoMCwgMTApOwpmb3IgKGNvbnN0IG5hbWUgb2YgZnMucmVhZGRpclN5bmMocGF0aC5qb2luKHJvb3QsICJkYWlseSIpKSkgewogIGlmICghL15cZHs0fS1cZHsyfS1cZHsyfVwuanNvbiQvaS50ZXN0KG5hbWUpKSBjb250aW51ZTsKICBpZiAoIW5hbWUuZW5kc1dpdGgoIi5qc29uIikpIHRocm93IG5ldyBFcnJvcigiRGF0ZWQgZGFpbHkgbWV0cmljcyBtdXN0IHVzZSBsb3dlcmNhc2UgLmpzb24gZmlsZW5hbWVzLiIpOwogIGNvbnN0IGRhdGUgPSBuYW1lLnNsaWNlKDAsIDEwKTsKICBjb25zdCBwYXJzZWQgPSBuZXcgRGF0ZShgJHtkYXRlfVQwMDowMDowMFpgKTsKICBpZiAoIU51bWJlci5pc0Zpbml0ZShwYXJzZWQuZ2V0VGltZSgpKSB8fCBwYXJzZWQudG9JU09TdHJpbmcoKS5zbGljZSgwLCAxMCkgIT09IGRhdGUpIHsKICAgIHRocm93IG5ldyBFcnJvcigiRGF0ZWQgZGFpbHkgbWV0cmljcyBtdXN0IHVzZSB2YWxpZCBjYWxlbmRhciBkYXRlcy4iKTsKICB9CiAgaWYgKGRhdGUgPCBvbGRlc3REYXkgfHwgZGF0ZSA+IGRheSkgewogICAgdGhyb3cgbmV3IEVycm9yKCJQcnVuZSBkYXRlZCBkYWlseSBtZXRyaWNzIG91dHNpZGUgdGhlIDMwLWRheSB1cGxvYWQgd2luZG93IGJlZm9yZSBwZXJzaXN0ZW5jZS4iKTsKICB9Cn0KbGV0IGFydGlmYWN0Qnl0ZXMgPSAwOwpsZXQgYXJ0aWZhY3RGaWxlcyA9IDA7CmZ1bmN0aW9uIGNoZWNrRGlyZWN0b3J5KGRpcmVjdG9yeSkgewogIGZvciAoY29uc3QgZW50cnkgb2YgZnMucmVhZGRpclN5bmMoZGlyZWN0b3J5LCB7IHdpdGhGaWxlVHlwZXM6IHRydWUgfSkpIHsKICAgIGNvbnN0IGZpbGVuYW1lID0gcGF0aC5qb2luKGRpcmVjdG9yeSwgZW50cnkubmFtZSk7CiAgICBpZiAoZW50cnkuaXNEaXJlY3RvcnkoKSkgewogICAgICBjaGVja0RpcmVjdG9yeShmaWxlbmFtZSk7CiAgICB9IGVsc2UgaWYgKHBhdGguZXh0bmFtZShlbnRyeS5uYW1lKS50b0xvd2VyQ2FzZSgpID09PSAiLmpzb24iKSB7CiAgICAgIGNvbnN0IGJ5dGVzID0gZnMuc3RhdFN5bmMoZmlsZW5hbWUpLnNpemU7CiAgICAgIGlmIChieXRlcyA+IG1heEZpbGVCeXRlcykgdGhyb3cgbmV3IEVycm9yKCJSZXRhaW5lZCBtZXRyaWNzIEpTT04gZXhjZWVkcyB0aGUgcGVyLWZpbGUgYnVkZ2V0LiIpOwogICAgICBhcnRpZmFjdEJ5dGVzICs9IGJ5dGVzOwogICAgICBhcnRpZmFjdEZpbGVzKys7CiAgICB9CiAgfQp9CmNoZWNrRGlyZWN0b3J5KHJvb3QpOwppZiAoYXJ0aWZhY3RCeXRlcyA+IG1heEFydGlmYWN0Qnl0ZXMgfHwgYXJ0aWZhY3RGaWxlcyA+IG1heEFydGlmYWN0RmlsZXMpIHsKICB0aHJvdyBuZXcgRXJyb3IoIk1ldHJpY3MgdXBsb2FkIGV4Y2VlZHMgdGhlIDE2TWlCIG9yIDEwMC1maWxlIGFydGlmYWN0IGJ1ZGdldC4iKTsKfQo= + VALIDATION_SCRIPT_REQUIRED: 'true' + VALIDATION_TIMEOUT_SECONDS: 60 with: script: | const path = require('path'); diff --git a/.github/workflows/metrics-collector.md b/.github/workflows/metrics-collector.md index 111f5e36047..ba9cc2785e7 100644 --- a/.github/workflows/metrics-collector.md +++ b/.github/workflows/metrics-collector.md @@ -26,7 +26,74 @@ tools: repo-memory: branch-name: memory/meta-orchestrators file-glob: "metrics/**" - max-patch-size: 131072 # 128KB - handles large daily metrics snapshots without patch-size gate failures + allowed-extensions: [".json"] + max-file-size: 524288 # 512KiB per complete snapshot (observed: 447189 bytes for 344 workflows) + max-patch-size: 1048576 # 1MiB for both daily and latest, including a first collection + validation: + script: | + const root = path.join(memoryRoot, "metrics"); + const latest = fs.readFileSync(path.join(root, "latest.json")); + const snapshot = JSON.parse(latest); + const timestamp = new Date(snapshot.timestamp); + if (!Number.isFinite(timestamp.getTime()) || + Date.now() - timestamp.getTime() > 86400000 || + timestamp.getTime() - Date.now() > 3600000) { + throw new Error("Latest metrics must have a timestamp in the current collection window."); + } + if (!snapshot.workflows || Array.isArray(snapshot.workflows) || + typeof snapshot.workflows !== "object" || Object.keys(snapshot.workflows).length === 0) { + throw new Error("Latest metrics must contain workflow records."); + } + const day = timestamp.toISOString().slice(0, 10); + const dailyPath = path.join(root, "daily", `${day}.json`); + if (!fs.existsSync(dailyPath) || !fs.readFileSync(dailyPath).equals(latest)) { + throw new Error("Latest metrics must exactly match the daily snapshot for its UTC timestamp."); + } + const maxFileBytes = 524288; + const maxPatchBytes = 1048576; + const maxArtifactBytes = 16777216; + const maxArtifactFiles = 100; + // A first collection adds both copies; git counts the '+' prefix on each line. + const addedBytes = latest.length + latest.toString("utf8").split("\n").length; + if (latest.length > maxFileBytes || 2 * addedBytes > maxPatchBytes) { + throw new Error("Complete metrics snapshot exceeds the file or two-copy patch budget; do not truncate workflows or fields."); + } + // Push validation sees historical backing-branch files omitted from the upload. + if (memoryPhase === "push") return; + const cutoff = new Date(`${day}T00:00:00Z`); + cutoff.setUTCDate(cutoff.getUTCDate() - 29); + const oldestDay = cutoff.toISOString().slice(0, 10); + for (const name of fs.readdirSync(path.join(root, "daily"))) { + if (!/^\d{4}-\d{2}-\d{2}\.json$/i.test(name)) continue; + if (!name.endsWith(".json")) throw new Error("Dated daily metrics must use lowercase .json filenames."); + const date = name.slice(0, 10); + const parsed = new Date(`${date}T00:00:00Z`); + if (!Number.isFinite(parsed.getTime()) || parsed.toISOString().slice(0, 10) !== date) { + throw new Error("Dated daily metrics must use valid calendar dates."); + } + if (date < oldestDay || date > day) { + throw new Error("Prune dated daily metrics outside the 30-day upload window before persistence."); + } + } + let artifactBytes = 0; + let artifactFiles = 0; + function checkDirectory(directory) { + for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { + const filename = path.join(directory, entry.name); + if (entry.isDirectory()) { + checkDirectory(filename); + } else if (path.extname(entry.name).toLowerCase() === ".json") { + const bytes = fs.statSync(filename).size; + if (bytes > maxFileBytes) throw new Error("Retained metrics JSON exceeds the per-file budget."); + artifactBytes += bytes; + artifactFiles++; + } + } + } + checkDirectory(root); + if (artifactBytes > maxArtifactBytes || artifactFiles > maxArtifactFiles) { + throw new Error("Metrics upload exceeds the 16MiB or 100-file artifact budget."); + } pre-agent-steps: - name: Record metrics baseline timestamp run: | @@ -339,6 +406,16 @@ Create a JSON object following this schema: **Latest Snapshot**: - Copy current metrics to: `/tmp/gh-aw/repo-memory/default/metrics/latest.json` - This provides quick access to most recent data without date calculations +- Keep both files byte-identical and retain every workflow and collected field. The storage + contract allows 512KiB per file and 1MiB per push for both copies, including first collection. + The observed 344-workflow snapshot is 447189 bytes (299336 even when compacted), so the old + 100KiB file / 128KiB patch budgets cannot store it. Do not drop data to meet a budget. +- Repo-memory uploads the retained directory as one Actions artifact, not a safe-output + `upload-artifact` operation. Its dated-snapshot portion is at most 31 files + and 15.5MiB at the file cap, below the default 100-changed-file persistence limit. + Supplemental JSON (for example an existing `daily/summary.json`) is preserved and remains + subject to file/count/patch checks; it is not a dated snapshot. The complete eligible + metrics upload, including supplemental JSON, is capped at 16MiB and 100 files by the validator. **Create Directory Structure**: ```bash @@ -362,18 +439,38 @@ jq . /tmp/gh-aw/repo-memory/default/metrics/latest.json >/dev/null && echo "JSON - ❌ `/tmp/gh-aw/repo-memory/default/agent-performance-latest.md` — NOT allowed (root level, wrong format) - ❌ `/tmp/gh-aw/repo-memory/default/anything-else.md` — NOT allowed -### 4. Cleanup Old Data +### 4. Bound the Upload Artifact -**Retention Policy**: -- Keep last 30 days of daily metrics -- Delete daily files older than 30 days from the metrics directory +**Upload Retention Policy**: +- Include the last 30 days of daily metrics in this run's upload artifact +- Remove daily files before the snapshot's UTC date minus 29 days (today included) from the upload directory - Preserve `latest.json` (always keep) -**Cleanup Command**: +Run this cleanup before calling `push_repo_memory` or finishing collection: + ```bash -find /tmp/gh-aw/repo-memory/default/metrics/daily/ -name "*.json" -mtime +30 -delete +node <<'NODE' +const fs = require("fs"); +const path = require("path"); +const root = "/tmp/gh-aw/repo-memory/default/metrics"; +const timestamp = JSON.parse(fs.readFileSync(path.join(root, "latest.json"), "utf8")).timestamp; +const cutoff = new Date(timestamp); +cutoff.setUTCDate(cutoff.getUTCDate() - 29); +const oldestDay = cutoff.toISOString().slice(0, 10); +for (const name of fs.readdirSync(path.join(root, "daily"))) { + if (/^\d{4}-\d{2}-\d{2}\.json$/.test(name) && name.slice(0, 10) < oldestDay) { + fs.unlinkSync(path.join(root, "daily", name)); + } +} +NODE ``` +The repo-memory validator rejects an unpruned upload before artifact creation. Do not use +`find -mtime`: checkout resets file modification times and would retain old snapshots forever. +Repo-memory overlays uploaded files on the backing branch; omitted files are not deleted there. +This bounds the current upload, not the historical files or Git history on the backing branch. +Consumers can still read older backing-branch snapshots; branch cleanup is a separate operation. + ### 5. Calculate Ecosystem Aggregates **Total Workflows**: @@ -574,10 +671,15 @@ find /tmp/gh-aw/repo-memory/default/metrics -type f | sort If the validation passes, proceed with the `noop` call. -#### Post-run Collection Gate (enforced automatically) +#### Persistence and Post-run Collection Gates (enforced automatically) -After the agent finishes, a workflow step verifies that collection actually happened and **fails the -run** when it did not. The run fails if any of the following is true: +Repo-memory validation runs before artifact upload and again before committing eligible uploaded +files. It rejects missing/invalid snapshots, empty workflows, stale/future timestamps, unequal +daily/latest copies and exceeded file/two-copy patch budgets in both phases. Before upload it also +checks the complete artifact budget and rejects unpruned or invalid dated history. Push-phase +validation does not impose upload retention on historical files already on the backing branch. +A separate post-step checks freshness against the pre-agent baseline; it is not the +pre-upload persistence gate. The run fails if any of the following is true: - `metrics/latest.json` was not written, or is not valid JSON - its `timestamp` field is missing, unparseable, or unchanged from the value present on the @@ -585,6 +687,15 @@ run** when it did not. The run fails if any of the following is true: - its `timestamp` is not within the current collection window - no file was written under `metrics/daily/` - `workflows` is empty +- the timestamp's daily snapshot is missing or not byte-identical to `latest.json` +- the complete snapshot exceeds 512KiB, or both copies with diff-line prefixes exceed 1MiB +- dated daily files are outside the 30-day upload window or use invalid calendar dates +- any retained JSON exceeds 512KiB, or the complete eligible upload exceeds 16MiB or 100 files + +The validator is read-only: run the cleanup command yourself before persistence. The push job +overlays eligible uploaded files without deleting older backing-branch files. Its generic file, +changed-file count, and patch checks remain fail-closed; its 20% patch overhead allowance is not +needed for a valid first collection. Consumer paths and the JSON schema are unchanged. This means an early exit after a couple of exploratory tool calls is reported as a failure instead of a green run with stale data. Always run the full collection loop described above. diff --git a/actions/setup/js/memory_custom_validation.cjs b/actions/setup/js/memory_custom_validation.cjs index b8312122874..00d62fe05ac 100644 --- a/actions/setup/js/memory_custom_validation.cjs +++ b/actions/setup/js/memory_custom_validation.cjs @@ -710,6 +710,7 @@ try { * memoryDir: string, * memoryId?: string, * kind: "repo" | "cache" | "drive", + * phase?: "baseline" | "upload" | "push", * timeoutSeconds?: number, * isEligibleFile?: (relativePath: string) => boolean, * }} options @@ -817,6 +818,7 @@ const memoryRoot = ${JSON.stringify(validationMemoryDir)}; const memoryDir = memoryRoot; const memoryId = ${JSON.stringify(memoryId)}; const memoryKind = ${JSON.stringify(options.kind)}; +const memoryPhase = ${JSON.stringify(options.phase || "upload")}; process.env.GH_AW_MEMORY_ROOT = memoryRoot; process.env.GH_AW_MEMORY_DIR = memoryRoot; process.env.GH_AW_MEMORY_ID = memoryId; diff --git a/actions/setup/js/memory_custom_validation.test.cjs b/actions/setup/js/memory_custom_validation.test.cjs index 99ce9f4a96e..26d881c0a1a 100644 --- a/actions/setup/js/memory_custom_validation.test.cjs +++ b/actions/setup/js/memory_custom_validation.test.cjs @@ -37,6 +37,25 @@ describe("memory_custom_validation", () => { expect(result.stdout).toContain("domain ok"); }); + it.each(["baseline", "upload", "push"])("provides runtime-controlled %s phase metadata", phase => { + const result = runCustomMemoryValidation({ + script: `if (memoryPhase !== ${JSON.stringify(phase)}) throw new Error("incorrect phase");`, + memoryDir: tempDir, + kind: "repo", + phase, + }); + expect(result.ok).toBe(true); + }); + + it("defaults existing validator callers to the upload phase", () => { + const result = runCustomMemoryValidation({ + script: 'if (memoryPhase !== "upload") throw new Error("incorrect default phase");', + memoryDir: tempDir, + kind: "cache", + }); + expect(result.ok).toBe(true); + }); + it("filters the disposable validation copy without changing sibling files", () => { fs.mkdirSync(path.join(tempDir, "nested")); fs.mkdirSync(path.join(tempDir, "sibling-only")); diff --git a/actions/setup/js/metrics_collector_workflow.test.cjs b/actions/setup/js/metrics_collector_workflow.test.cjs new file mode 100644 index 00000000000..26e7e759a9e --- /dev/null +++ b/actions/setup/js/metrics_collector_workflow.test.cjs @@ -0,0 +1,355 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import vm from "node:vm"; +import { execFileSync } from "node:child_process"; +import { createRequire } from "node:module"; +import { getPatchDiffSizeBytes } from "./git_patch_utils.cjs"; +import { validateMemoryStep, validateRepoMemoryBaseline } from "./validate_memory_step.cjs"; +import { getRepoMemoryBaselinePath } from "./memory_custom_validation.cjs"; + +const require = createRequire(import.meta.url); +const repoRoot = path.resolve(import.meta.dirname, "../../.."); +const source = fs.readFileSync(path.join(repoRoot, ".github/workflows/metrics-collector.md"), "utf8"); +const frontmatter = source.split("---")[1]; +const maxFileBytes = Number(frontmatter.match(/max-file-size: (\d+)/)[1]); +const maxPatchBytes = Number(frontmatter.match(/max-patch-size: (\d+)/)[1]); +const gate = source.match(/ validation:\n script: \|\n([\s\S]*?)\npre-agent-steps:/)[1].replace(/^ /gm, ""); +const prune = source.match(/node <<'NODE'\n([\s\S]*?)\nNODE/)[1]; +const example = JSON.parse(source.match(/Create a JSON object following this schema:\n\n```json\n([\s\S]*?)\n```/)[1]); +const tempDirs = []; + +function temporaryDirectory() { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "metrics-contract-")); + tempDirs.push(dir); + return dir; +} + +function snapshot(bytes = 447189, timestamp = "2026-10-14T02:00:00Z") { + const data = { + ...example, + timestamp, + workflows: Object.fromEntries(Array.from({ length: 344 }, (_, index) => [`workflow-${index}`, example.workflows["workflow-name"]])), + ecosystem: { ...example.ecosystem, total_workflows: 344 }, + collection_note: "", + }; + const initialBytes = Buffer.byteLength(JSON.stringify(data, null, 2) + "\n"); + if (initialBytes > bytes) throw new Error(`Fixture schema is already ${initialBytes} bytes, exceeding requested ${bytes}`); + data.collection_note = "x".repeat(bytes - initialBytes); + return JSON.stringify(data, null, 2) + "\n"; +} + +function writeSnapshot(root, content = snapshot()) { + fs.mkdirSync(path.join(root, "daily"), { recursive: true }); + fs.writeFileSync(path.join(root, "latest.json"), content); + const day = JSON.parse(content).timestamp.slice(0, 10); + fs.writeFileSync(path.join(root, "daily", `${day}.json`), content); + return content; +} + +function runGate(root, script = gate, phase = "upload") { + class FixtureDate extends Date { + static now() { + return Date.parse("2026-10-14T03:00:00Z"); + } + } + vm.runInNewContext(`(function () { ${script.replace('path.join(memoryRoot, "metrics")', JSON.stringify(root))} })()`, { + fs, + path, + Buffer, + memoryPhase: phase, + Date: FixtureDate, + }); +} + +function runPrune(root) { + vm.runInNewContext(prune.replace('"/tmp/gh-aw/repo-memory/default/metrics"', JSON.stringify(root)), { require }); +} + +function git(root, args) { + return execFileSync("git", args, { cwd: root, encoding: "utf8", stdio: "pipe" }); +} + +function initializeRepository() { + const root = temporaryDirectory(); + git(root, ["init", "--quiet"]); + git(root, ["config", "user.name", "Metrics test"]); + git(root, ["config", "user.email", "metrics-test@example.invalid"]); + git(root, ["config", "core.hooksPath", "/dev/null"]); + git(root, ["remote", "add", "origin", path.join(root, "unused-local-remote")]); + git(root, ["commit", "--quiet", "--allow-empty", "-m", "Fixture baseline"]); + return root; +} + +afterEach(() => { + for (const dir of tempDirs.splice(0)) fs.rmSync(dir, { recursive: true, force: true }); + vi.unstubAllEnvs(); +}); + +describe("Metrics Collector serialized storage contract", () => { + it("keeps the gate budgets consistent with repo-memory configuration", () => { + expect(Number(gate.match(/maxFileBytes = (\d+)/)[1])).toBe(maxFileBytes); + expect(Number(gate.match(/maxPatchBytes = (\d+)/)[1])).toBe(maxPatchBytes); + expect(maxPatchBytes).toBeLessThanOrEqual(1048576); + expect(frontmatter).toContain('file-glob: "metrics/**"'); + }); + + it("emits the same validator and budgets before upload and in the push job", () => { + const lock = fs.readFileSync(path.join(repoRoot, ".github/workflows/metrics-collector.lock.yml"), "utf8"); + const scripts = [...lock.matchAll(/VALIDATION_SCRIPT_B64: ([A-Za-z0-9+/=]+)/g)]; + expect(scripts.length).toBeGreaterThanOrEqual(3); + for (const [, encoded] of scripts) expect(Buffer.from(encoded, "base64").toString("utf8").trim()).toBe(gate.trim()); + expect(Number(lock.match(/MAX_FILE_SIZE: (\d+)/)[1])).toBe(maxFileBytes); + expect(Number(lock.match(/MAX_PATCH_SIZE: (\d+)/)[1])).toBe(maxPatchBytes); + const countLimit = Number(lock.match(/MAX_FILE_COUNT: (\d+)/)[1]); + expect(Number(gate.match(/maxArtifactFiles = (\d+)/)[1])).toBe(countLimit); + expect(lock.indexOf("Validate repo-memory domain content (default)")).toBeLessThan(lock.indexOf("Upload repo-memory artifact (default)")); + const upload = lock.match(/- name: Upload repo-memory artifact \(default\)\n([\s\S]*?)\n - name:/)[1]; + expect(upload).toContain("steps.validate_repo_memory_64656661756c74.outcome == 'success'"); + expect(lock).toContain("ALLOWED_EXTENSIONS: '[\".json\"]'"); + expect(lock).toContain('FILE_GLOB_FILTER: "metrics/**"'); + }); + + it("fits a full measured-size 344-workflow snapshot without dropping fields", () => { + const root = temporaryDirectory(); + const content = writeSnapshot(root); + expect(Buffer.byteLength(content)).toBe(447189); + expect(Buffer.byteLength(content)).toBeGreaterThan(102400); + expect(Buffer.byteLength(JSON.stringify(JSON.parse(content)))).toBeGreaterThan(131072); + expect(Buffer.byteLength(content)).toBeLessThan(maxFileBytes); + expect(Object.keys(JSON.parse(content).workflows)).toHaveLength(344); + runGate(root); + expect(fs.readFileSync(path.join(root, "latest.json"), "utf8")).toBe(content); + expect(fs.readFileSync(path.join(root, "daily/2026-10-14.json"), "utf8")).toBe(content); + }); + + it.each(["bootstrap", "next-day", "both-replaced"])("measures the actual %s staged diff, including prefixes and headers", mode => { + const root = initializeRepository(); + const metrics = path.join(root, "metrics"); + if (mode !== "bootstrap") { + writeSnapshot(metrics, snapshot(447189, mode === "next-day" ? "2026-10-13T02:00:00Z" : "2026-10-14T01:00:00Z")); + git(root, ["add", "."]); + git(root, ["commit", "--quiet", "-m", "Previous metrics"]); + } + const content = writeSnapshot(metrics, snapshot(480000)); + runGate(metrics); + git(root, ["add", "."]); + const diff = git(root, ["diff", "--cached", "--no-renames"]); + const enforcedBytes = getPatchDiffSizeBytes(diff); + expect(enforcedBytes).toBeGreaterThan(0); + expect(enforcedBytes).toBeLessThan(maxPatchBytes); + // Headers/context are not charged by the runtime's net-byte helper, but still fit. + expect(Buffer.byteLength(diff)).toBeLessThan(maxPatchBytes); + if (mode === "bootstrap") { + expect(enforcedBytes).toBeGreaterThan(2 * Buffer.byteLength(content)); + expect(enforcedBytes).toBeGreaterThan(131072 * 1.2); + } + }); + + it("fails closed at the file cap without altering oversized data", () => { + const root = temporaryDirectory(); + const content = writeSnapshot(root, snapshot(maxFileBytes + 1)); + expect(() => runGate(root)).toThrow("exceeds the file or two-copy patch budget"); + expect(fs.readFileSync(path.join(root, "latest.json"), "utf8")).toBe(content); + }); + + it("fails closed when line-prefix overhead exceeds the two-copy patch cap", () => { + const root = temporaryDirectory(); + const content = writeSnapshot(root); + const fileBytes = Buffer.byteLength(content); + const tightBudget = 2 * fileBytes; + expect(() => runGate(root, gate.replace(`maxPatchBytes = ${maxPatchBytes}`, `maxPatchBytes = ${tightBudget}`))).toThrow("two-copy patch budget"); + }); + + it.each(["missing", "different", "invalid-json"])("rejects a %s daily/latest pair", mode => { + const root = temporaryDirectory(); + writeSnapshot(root); + if (mode === "missing") fs.unlinkSync(path.join(root, "daily/2026-10-14.json")); + if (mode === "different") fs.writeFileSync(path.join(root, "daily/2026-10-14.json"), "{}"); + if (mode === "invalid-json") fs.writeFileSync(path.join(root, "latest.json"), "{"); + expect(() => runGate(root)).toThrow(); + }); + + it("bounds the artifact to 30 dated snapshots plus latest regardless of checkout mtimes, not backing-branch history", () => { + const artifact = temporaryDirectory(); + const branch = temporaryDirectory(); + const content = writeSnapshot(artifact); + fs.mkdirSync(path.join(branch, "daily")); + for (let offset = 0; offset < 45; offset++) { + const date = new Date("2026-10-14T00:00:00Z"); + date.setUTCDate(date.getUTCDate() - offset); + const filename = `${date.toISOString().slice(0, 10)}.json`; + // Every checkout file has a fresh mtime, including historical snapshots. + fs.writeFileSync(path.join(artifact, "daily", filename), content); + fs.writeFileSync(path.join(branch, "daily", filename), content); + } + expect(() => runGate(artifact)).toThrow("30-day upload window"); + runPrune(artifact); + runGate(artifact); + const dailyFiles = fs.readdirSync(path.join(artifact, "daily")).sort(); + expect(dailyFiles).toHaveLength(30); + expect(dailyFiles[0]).toBe("2026-09-15.json"); + expect(fs.readdirSync(path.join(branch, "daily"))).toHaveLength(45); + const files = [path.join(artifact, "latest.json"), ...dailyFiles.map(name => path.join(artifact, "daily", name))]; + expect(files).toHaveLength(31); + expect(files.length).toBeLessThan(100); // Default changed-file limit. + const artifactBytes = files.reduce((total, file) => total + fs.statSync(file).size, 0); + expect(artifactBytes).toBe(31 * 447189); + expect(artifactBytes).toBeLessThanOrEqual(31 * maxFileBytes); + }); + + it("allows omitted backing-branch history only in the push phase", () => { + const root = temporaryDirectory(); + writeSnapshot(root); + fs.writeFileSync(path.join(root, "daily/2026-01-01.json"), "{}"); + expect(() => runGate(root)).toThrow("30-day upload window"); + expect(() => runGate(root, gate, "baseline")).toThrow("30-day upload window"); + runGate(root, gate, "push"); + expect(fs.existsSync(path.join(root, "daily/2026-01-01.json"))).toBe(true); + fs.writeFileSync(path.join(root, "daily/2026-10-14.json"), "{}"); + expect(() => runGate(root, gate, "push")).toThrow("exactly match"); + }); + + it.each(["2026-09-99.json", "2026-09-31.json"])("rejects impossible calendar dates: %s", name => { + const root = temporaryDirectory(); + writeSnapshot(root); + fs.writeFileSync(path.join(root, "daily", name), "{}"); + expect(() => runGate(root)).toThrow("valid calendar dates"); + }); + + it.each(["json", "JSON", "JsOn"])("rejects an oversized retained .%s file before upload", extension => { + const root = temporaryDirectory(); + writeSnapshot(root); + fs.writeFileSync(path.join(root, `retained.${extension}`), snapshot(maxFileBytes + 1, "2026-10-13T02:00:00Z")); + expect(() => runGate(root)).toThrow("Retained metrics JSON"); + }); + + it.each([ + ["bytes", "json"], + ["bytes", "JSON"], + ["bytes", "JsOn"], + ["files", "json"], + ["files", "JSON"], + ["files", "JsOn"], + ])("enforces the complete artifact %s cap, including nested supplemental .%s", (budget, extension) => { + const root = temporaryDirectory(); + writeSnapshot(root); + fs.mkdirSync(path.join(root, "extra")); + const count = budget === "bytes" ? 40 : 100; + for (let index = 0; index < count; index++) { + fs.writeFileSync(path.join(root, "extra", `${index}.${extension}`), budget === "bytes" ? snapshot() : "{}"); + } + expect(() => runGate(root)).toThrow("16MiB or 100-file artifact budget"); + }); + + it.each(["JSON", "JsOn"])("rejects alternate-case dated .%s filenames", extension => { + const root = temporaryDirectory(); + writeSnapshot(root); + fs.writeFileSync(path.join(root, `daily/2026-10-13.${extension}`), "{}"); + expect(() => runGate(root)).toThrow("lowercase .json"); + }); + + it("preserves supplemental JSON rather than manufacturing success by dropping data", () => { + const root = temporaryDirectory(); + writeSnapshot(root); + fs.writeFileSync(path.join(root, "daily/summary.json"), '{"note":"supplemental data"}\n'); + runGate(root); + expect(fs.readFileSync(path.join(root, "daily/summary.json"), "utf8")).toBe('{"note":"supplemental data"}\n'); + }); + + it("rejects a future dated snapshot", () => { + const root = temporaryDirectory(); + writeSnapshot(root); + fs.writeFileSync(path.join(root, "daily/2026-10-15.json"), "{}"); + expect(() => runGate(root)).toThrow(); + }); + + it.each(["upload", "baseline"])("wires the actual %s validation entry point and fails closed before upload", phase => { + const root = temporaryDirectory(); + writeSnapshot(path.join(root, "metrics"), snapshot(447189, new Date().toISOString())); + const memoryId = path.basename(root); + const script = `if (memoryPhase !== ${JSON.stringify(phase)}) throw new Error("incorrect entry-point phase");\n${gate}`; + vi.stubEnv("MEMORY_DIR", root); + vi.stubEnv("MEMORY_ID", memoryId); + vi.stubEnv("ALLOWED_EXTENSIONS", '[".json"]'); + vi.stubEnv("FILE_GLOB_FILTER", "metrics/**"); + vi.stubEnv("VALIDATION_SCRIPT_B64", Buffer.from(script).toString("base64")); + const core = { info: vi.fn(), warning: vi.fn(), setFailed: vi.fn() }; + const baselinePath = getRepoMemoryBaselinePath(memoryId); + try { + if (phase === "upload") { + expect(validateMemoryStep(core, { kind: "repo" })).toBe(true); + fs.writeFileSync(path.join(root, "metrics/daily/2020-01-01.json"), "{}"); + expect(validateMemoryStep(core, { kind: "repo" })).toBe(false); + expect(core.setFailed).toHaveBeenCalled(); + } else { + validateRepoMemoryBaseline(core); + expect(JSON.parse(fs.readFileSync(baselinePath, "utf8")).ok).toBe(true); + fs.writeFileSync(path.join(root, "metrics/daily/2020-01-01.json"), "{}"); + validateRepoMemoryBaseline(core); + expect(JSON.parse(fs.readFileSync(baselinePath, "utf8")).ok).toBe(false); + expect(core.warning).toHaveBeenCalled(); + } + } finally { + fs.rmSync(baselinePath, { force: true }); + fs.rmSync(baselinePath.replace(/\.json$/, ".log"), { force: true }); + } + }); + + it.each(["current", "old-file-cap", "old-patch-cap", "invalid-pair"])("exercises the real push validator with %s budgets and mocked external effects", async mode => { + const workspace = initializeRepository(); + const artifact = temporaryDirectory(); + const current = snapshot(447189, new Date().toISOString()); + writeSnapshot(path.join(artifact, "metrics"), current); + fs.mkdirSync(path.join(workspace, "metrics/daily"), { recursive: true }); + fs.writeFileSync(path.join(workspace, "metrics/daily/2020-01-01.json"), "{}"); + git(workspace, ["add", "."]); + git(workspace, ["commit", "--quiet", "-m", "Backing branch history"]); + if (mode === "invalid-pair") { + const day = JSON.parse(current).timestamp.slice(0, 10); + fs.writeFileSync(path.join(artifact, "metrics/daily", `${day}.json`), "{}"); + } + const core = { info: vi.fn(), warning: vi.fn(), error: vi.fn(), setFailed: vi.fn(), setOutput: vi.fn() }; + const pushSignedCommits = vi.fn().mockResolvedValue(undefined); + const execGitSync = (args, options = {}) => { + if (args[0] === "fetch" || args[0] === "checkout") return ""; + return git(options.cwd || workspace, args); + }; + const mocks = { "./git_helpers.cjs": { execGitSync }, "./push_signed_commits.cjs": { pushSignedCommits } }; + const module = { exports: {} }; + vm.runInNewContext(fs.readFileSync(path.join(import.meta.dirname, "push_repo_memory.cjs"), "utf8"), { + require: id => mocks[id] || require(id), + module, + core, + context: { repo: { owner: "owner", repo: "repo" } }, + github: {}, + exec: {}, + process: { + env: { + ARTIFACT_DIR: artifact, + GITHUB_WORKSPACE: workspace, + MEMORY_ID: "default", + TARGET_REPO: "owner/repo", + BRANCH_NAME: "memory/meta-orchestrators", + GH_TOKEN: "fixture-token", + FILE_GLOB_FILTER: "metrics/**", + ALLOWED_EXTENSIONS: '[".json"]', + MAX_FILE_SIZE: String(mode === "old-file-cap" ? 102400 : maxFileBytes), + MAX_PATCH_SIZE: String(mode === "old-patch-cap" ? 131072 : maxPatchBytes), + VALIDATION_SCRIPT_B64: Buffer.from(gate).toString("base64"), + }, + }, + }); + await module.exports.main(); + if (mode === "current") { + expect(core.setFailed).not.toHaveBeenCalled(); + expect(pushSignedCommits).toHaveBeenCalledOnce(); + expect(fs.readFileSync(path.join(workspace, "metrics/latest.json"), "utf8")).toBe(current); + expect(fs.existsSync(path.join(workspace, "metrics/daily/2020-01-01.json"))).toBe(true); + } else { + expect(core.setFailed).toHaveBeenCalled(); + expect(pushSignedCommits).not.toHaveBeenCalled(); + } + }); +}); diff --git a/actions/setup/js/push_repo_memory.cjs b/actions/setup/js/push_repo_memory.cjs index 4067760cc68..6849aac208e 100644 --- a/actions/setup/js/push_repo_memory.cjs +++ b/actions/setup/js/push_repo_memory.cjs @@ -828,6 +828,7 @@ async function main() { memoryDir: destMemoryPath, memoryId, kind: "repo", + phase: "push", timeoutSeconds: validationTimeoutSeconds, isEligibleFile, }); @@ -1000,6 +1001,7 @@ async function main() { memoryDir: destMemoryPath, memoryId, kind: "repo", + phase: "push", timeoutSeconds: validationTimeoutSeconds, isEligibleFile, }); diff --git a/actions/setup/js/validate_memory_step.cjs b/actions/setup/js/validate_memory_step.cjs index cf0dc5128e7..34d07c480f7 100644 --- a/actions/setup/js/validate_memory_step.cjs +++ b/actions/setup/js/validate_memory_step.cjs @@ -63,6 +63,7 @@ function validateMemoryStep(core, options) { memoryDir, memoryId, kind: options.kind, + phase: "upload", timeoutSeconds: Number(process.env.VALIDATION_TIMEOUT_SECONDS || "30"), }); if (result.stdout) { @@ -99,6 +100,7 @@ function validateRepoMemoryBaseline(core) { memoryDir, memoryId, kind: "repo", + phase: "baseline", timeoutSeconds: Number(process.env.VALIDATION_TIMEOUT_SECONDS || "30"), }); } catch (error) { diff --git a/docs/src/content/docs/reference/repo-memory.md b/docs/src/content/docs/reference/repo-memory.md index 443e06a8faf..022100bd6c9 100644 --- a/docs/src/content/docs/reference/repo-memory.md +++ b/docs/src/content/docs/reference/repo-memory.md @@ -203,7 +203,9 @@ Use `validation.script` when generic storage and schema checks are not enough. T The script also checks the cloned baseline before the agent starts, without formatting or filtering the checkout. A baseline failure is shown to the agent with the validator's output so it can repair memory and call `push_repo_memory` to verify the change. If an invalid baseline remains unchanged, the agent job warns and skips its memory artifact instead of failing after the agent exits. Any changed memory still requires successful validation before upload and again before the push. -Available globals are Node.js `fs` and `path`, plus `memoryRoot`/`memoryDir`, `memoryId`, and `memoryKind` (`"repo"`). These paths and the working directory point to a temporary copy of the eligible memory files, excluding the checkout's `.git` directory. Changes to this copy are not persisted and do not modify the original memory directory. This copy is not a sandbox: validators remain trusted code with the runner's filesystem permissions. +Available globals are Node.js `fs` and `path`, plus `memoryRoot`/`memoryDir`, `memoryId`, `memoryKind` (`"repo"`), and `memoryPhase` (`"baseline"`, `"upload"`, or `"push"`). These paths and the working directory point to a temporary copy of the eligible memory files, excluding the checkout's `.git` directory. Changes to this copy are not persisted and do not modify the original memory directory. This copy is not a sandbox: validators remain trusted code with the runner's filesystem permissions. + +`memoryPhase` is set by the runtime, not memory contents. Use it to distinguish upload-directory limits from backing-branch checks: `"push"` sees the complete branch overlay, including historical files omitted from the upload. Repo-memory does not delete those omitted files. Keep snapshot integrity checks in every phase; apply upload-only retention or aggregate artifact limits during `"upload"` (and optionally `"baseline"` for repair diagnostics). Environment variables available to the validator are intentionally limited to basic runner paths plus `GH_AW_MEMORY_ROOT`, `GH_AW_MEMORY_DIR`, `GH_AW_MEMORY_ID`, and `GH_AW_MEMORY_KIND`; GitHub tokens and write credentials are not passed to the validator subprocess. Network access follows the workflow runner's normal network policy. The default timeout is 1 minute and may be set with `validation.timeout-minutes` (1-5 minutes).