From 9bc357d5da545adb7343a8b94c634390d7200b18 Mon Sep 17 00:00:00 2001 From: jadamcrain Date: Fri, 16 Oct 2026 08:36:31 -0700 Subject: [PATCH 1/6] Point CLAUDE.md at the private dnp3-reference repository --- CLAUDE.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 9faa6a69..2ac9fecc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -22,4 +22,10 @@ - **Naming**: Follow Rust naming conventions (snake_case for functions/variables, CamelCase for types) - **Imports**: Organize imports by standard lib, external crates, then internal modules - **Features**: Support compile-time features (tls, serial, serialization) -- **Minimum Rust Version**: 1.85+ (the workspace `rust-version`, checked by the `msrv` CI job) \ No newline at end of file +- **Minimum Rust Version**: 1.85+ (the workspace `rust-version`, checked by the `msrv` CI job) + +## Reference Documents +- The private repository `stepfunc/dnp3-reference`, checked out next to this one at `../dnp3-reference`, holds the DNP3 source documents: IEEE 1815-2012 and related standards, the DNP Users Group technical bulletins, and the IED certification test procedures, with searchable Markdown extractions and recorded interpretation decisions +- Use it to answer questions about what the spec or test procedures require. Read its `CLAUDE.md` first: it explains how to find and cite clauses, and that technical bulletins can amend 1815-2012 +- If it isn't checked out, ask the user for it rather than working from memory of the spec +- **Keep it private**: the documents are copyrighted. This repository is public, so never copy their text, tables, or figures into code, comments, docs, commit messages, issues, or PRs. Cite by document and clause number instead (e.g. "IEEE 1815-2012 4.5.10", "TB2016-001") \ No newline at end of file From fc9da05337a295cb1cdbb920c3fb30b420e8d34a Mon Sep 17 00:00:00 2001 From: jadamcrain Date: Fri, 16 Oct 2026 09:01:35 -0700 Subject: [PATCH 2/6] Run the conformance tests on labeled PRs, weekly, and for releases Move the conformance jobs into conformance.yml, which runs on PRs labeled conformance, weekly on main, on demand, and when ci.yml calls it for a release tag. Document when to apply the label and how releases check conformance before tagging. Clarify the Reference Documents section. --- .github/workflows/ci.yml | 71 ++-------------------------- .github/workflows/conformance.yml | 78 +++++++++++++++++++++++++++++++ CLAUDE.md | 12 +++-- RELEASE.md | 9 +++- 4 files changed, 97 insertions(+), 73 deletions(-) create mode 100644 .github/workflows/conformance.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 45f9611c..c4b0ec21 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -359,75 +359,10 @@ jobs: with: name: java-bindings-pom path: ffi/bindings/java/dnp3/pom.xml - # Run the conformance tests, for each revision of the test procedures at the subset level it tests - conformance-run: - name: conformance (${{ matrix.revision }}, level ${{ matrix.level }}) - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - include: - - revision: "2.7" - level: 2 - - revision: "3.1r1" - level: 3 - steps: - - name: Checkout - uses: actions/checkout@v6 - - name: Install Rust - uses: dtolnay/rust-toolchain@stable - - name: Build JNI - run: cargo build --release -p dnp3-ffi-java - - name: Build Java bindings - run: cargo run --release --bin dnp3-bindings -- --java - - name: Install Java bindings - shell: bash - run: (cd ffi/bindings/java/dnp3 && sudo mvn install) - - name: Capture packets during the conformance tests - working-directory: conformance - # capturing needs root, which the tests run as; local runs leave it off - run: echo "tcp.capture.adapter = /127.0.0.1" >> device.ini - - name: Select the subset level - working-directory: conformance - # device.ini is for 3.1r1 at level 3; a later value overrides it - run: echo "outstation.subset.level = ${{ matrix.level }}" >> device.ini - - name: Run the conformance tests - working-directory: conformance - run: sudo mvn --batch-mode scala:run -Dconformance.revision=${{ matrix.revision }} - - name: Check the packet captures - # dnp3c keeps each revision's results, for each subset level, in a folder of its own - working-directory: conformance/results/${{ matrix.revision }}/L${{ matrix.level }} - # every procedure whose log shows a transmitted frame ("--P->") must have captured packets; - # a pcap of only its 24-octet header captured none - run: | - sent=0 - empty=0 - for dir in */; do - grep -qE -- '--P-(>|>)' "$dir/log.html" 2>/dev/null || continue - sent=$((sent + 1)) - if [ ! -f "$dir/dump.pcap" ] || [ "$(stat -c %s "$dir/dump.pcap")" -le 24 ]; then - echo "No packets captured for ${dir%/}" - empty=$((empty + 1)) - fi - done - echo "$sent procedures sent frames; $empty captured no packets" - [ "$sent" -gt 0 ] && [ "$empty" -eq 0 ] - - name: Upload conformance test results - if: always() - uses: actions/upload-artifact@v7 - with: - name: conformance-results-${{ matrix.revision }} - path: conformance/results - # The check branch protection requires: passes only if every revision's run passed + # Run the conformance tests for releases; PRs labeled `conformance` run them through conformance.yml itself conformance: - needs: [conformance-run] - if: always() - runs-on: ubuntu-latest - steps: - - name: Check the conformance runs - run: | - echo "conformance runs: ${{ needs.conformance-run.result }}" - [ "${{ needs.conformance-run.result }}" = "success" ] + if: startsWith(github.ref, 'refs/tags/') + uses: ./.github/workflows/conformance.yml # Quality gate to ensure all tests pass before releases quality-gate: needs: [linting, msrv, test, features, conformance] diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml new file mode 100644 index 00000000..5837b9a1 --- /dev/null +++ b/.github/workflows/conformance.yml @@ -0,0 +1,78 @@ +name: Conformance +on: + # Runs on PRs only when they carry the `conformance` label (see CLAUDE.md) + pull_request: + types: [opened, synchronize, reopened, labeled] + # Best effort: GitHub may delay or drop scheduled runs + schedule: + - cron: "17 6 * * 1" + workflow_dispatch: + # ci.yml calls this for release tags, so quality-gate waits on it and the release jobs publish its results + workflow_call: +permissions: + contents: read +jobs: + # Run the conformance tests, for each revision of the test procedures at the subset level it tests + conformance-run: + name: conformance (${{ matrix.revision }}, level ${{ matrix.level }}) + # on PRs, run when labeled `conformance`, and not again when some other label is added + if: >- + github.event_name != 'pull_request' || + (contains(github.event.pull_request.labels.*.name, 'conformance') && + (github.event.action != 'labeled' || github.event.label.name == 'conformance')) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - revision: "2.7" + level: 2 + - revision: "3.1r1" + level: 3 + steps: + - name: Checkout + uses: actions/checkout@v6 + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + - name: Build JNI + run: cargo build --release -p dnp3-ffi-java + - name: Build Java bindings + run: cargo run --release --bin dnp3-bindings -- --java + - name: Install Java bindings + shell: bash + run: (cd ffi/bindings/java/dnp3 && sudo mvn install) + - name: Capture packets during the conformance tests + working-directory: conformance + # capturing needs root, which the tests run as; local runs leave it off + run: echo "tcp.capture.adapter = /127.0.0.1" >> device.ini + - name: Select the subset level + working-directory: conformance + # device.ini is for 3.1r1 at level 3; a later value overrides it + run: echo "outstation.subset.level = ${{ matrix.level }}" >> device.ini + - name: Run the conformance tests + working-directory: conformance + run: sudo mvn --batch-mode scala:run -Dconformance.revision=${{ matrix.revision }} + - name: Check the packet captures + # dnp3c keeps each revision's results, for each subset level, in a folder of its own + working-directory: conformance/results/${{ matrix.revision }}/L${{ matrix.level }} + # every procedure whose log shows a transmitted frame ("--P->") must have captured packets; + # a pcap of only its 24-octet header captured none + run: | + sent=0 + empty=0 + for dir in */; do + grep -qE -- '--P-(>|>)' "$dir/log.html" 2>/dev/null || continue + sent=$((sent + 1)) + if [ ! -f "$dir/dump.pcap" ] || [ "$(stat -c %s "$dir/dump.pcap")" -le 24 ]; then + echo "No packets captured for ${dir%/}" + empty=$((empty + 1)) + fi + done + echo "$sent procedures sent frames; $empty captured no packets" + [ "$sent" -gt 0 ] && [ "$empty" -eq 0 ] + - name: Upload conformance test results + if: always() + uses: actions/upload-artifact@v7 + with: + name: conformance-results-${{ matrix.revision }} + path: conformance/results diff --git a/CLAUDE.md b/CLAUDE.md index 2ac9fecc..6a238664 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,6 +14,11 @@ - Check lints: `cargo clippy` - Run with logging: `RUST_LOG=debug cargo run --example outstation` +## Conformance Tests +- `.github/workflows/conformance.yml` runs the DNP3 IED certification procedures against the outstation with dnp3c (about 12 minutes). It runs on release tags (gating publishing), weekly on `main`, on demand (`gh workflow run conformance.yml --ref `), and on PRs labeled `conformance` +- Label a PR `conformance` (`gh pr create --label conformance` or `gh pr edit --add-label conformance`) when it changes what the outstation sends or accepts: the outstation, the application, transport, or link layers, or the Java FFI the tests drive. Don't label master-only, docs, CI, or other-binding changes +- On PRs the result is advisory, not a required check, so look at it before merging + ## Code Style Guidelines - **No unsafe code**: Unsafe code is forbidden (`unsafe_code = "forbid"`) - **Documentation**: All public items must be documented (`missing_docs = "deny"`) @@ -25,7 +30,8 @@ - **Minimum Rust Version**: 1.85+ (the workspace `rust-version`, checked by the `msrv` CI job) ## Reference Documents -- The private repository `stepfunc/dnp3-reference`, checked out next to this one at `../dnp3-reference`, holds the DNP3 source documents: IEEE 1815-2012 and related standards, the DNP Users Group technical bulletins, and the IED certification test procedures, with searchable Markdown extractions and recorded interpretation decisions -- Use it to answer questions about what the spec or test procedures require. Read its `CLAUDE.md` first: it explains how to find and cite clauses, and that technical bulletins can amend 1815-2012 +- The private repository `stepfunc/dnp3-reference`, checked out next to this one at `../dnp3-reference`, holds the DNP3 source documents: IEEE 1815-2012 and related standards, the DNP Users Group technical bulletins, and the IED certification test procedures. Some documents (1815-2012 and the 2.7 and 3.1r1 test procedures) have searchable Markdown extractions; the bulletins are PDFs with an index of what each one changes. It also records our interpretation decisions +- Use it to answer questions about what the spec or test procedures require. Read its `CLAUDE.md` first and follow its guidance on reading, citing, and interpreting the documents. Before relying on a 1815-2012 requirement, check its bulletin index for amendments +- Its rules for maintaining that repository (such as committing straight to `main`) apply only there, not here - If it isn't checked out, ask the user for it rather than working from memory of the spec -- **Keep it private**: the documents are copyrighted. This repository is public, so never copy their text, tables, or figures into code, comments, docs, commit messages, issues, or PRs. Cite by document and clause number instead (e.g. "IEEE 1815-2012 4.5.10", "TB2016-001") \ No newline at end of file +- **Keep it private**: the documents are copyrighted. This repository is public, so never copy or upload their PDFs, extractions, screenshots, text, tables, or figures into code, comments, docs, commit messages, issues, PRs, or attachments. Cite by document and clause or test number instead (e.g. "IEEE 1815-2012 4.5.10", "TB2016-001") \ No newline at end of file diff --git a/RELEASE.md b/RELEASE.md index d60ce1c1..8592b1c3 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -23,11 +23,16 @@ changed, refresh `allowed.json` so the packaging license check passes, and verify any dependency/security claims in the changelog against the actual `Cargo.lock` versions. -Open the PR and merge it to `main`. +Open the PR with the `conformance` label (`gh pr create --label conformance`). +The conformance tests don't run on unlabeled PRs, and a failure on the tag +blocks every publisher, so merge the PR to `main` only once both conformance +jobs are green on its latest revision, after its final update with `main`. ## 2. Tag on `main` and push -CI only publishes from tags, and by convention tags live on `main`: +CI only publishes from tags, and by convention tags live on `main`. Tag the +commit the `prepare` PR's merge produced. If you tag a later commit, run the +conformance tests on it first (`gh workflow run conformance.yml --ref main`). ```bash git checkout main && git pull From 420a23ae285c98247f43d193ca684e1506428d93 Mon Sep 17 00:00:00 2001 From: jadamcrain Date: Fri, 16 Oct 2026 09:01:35 -0700 Subject: [PATCH 3/6] TEMPORARY: test the reusable conformance workflow's artifacts --- .github/workflows/conformance-call-test.yml | 35 +++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 .github/workflows/conformance-call-test.yml diff --git a/.github/workflows/conformance-call-test.yml b/.github/workflows/conformance-call-test.yml new file mode 100644 index 00000000..3d8b83e5 --- /dev/null +++ b/.github/workflows/conformance-call-test.yml @@ -0,0 +1,35 @@ +# TEMPORARY: checks that ci.yml's call into conformance.yml hands the release jobs their artifacts. Remove before merging. +name: Conformance call test +on: + pull_request: + types: [opened, synchronize, reopened, labeled] +jobs: + conformance: + uses: ./.github/workflows/conformance.yml + check-artifacts: + needs: [conformance] + # the called jobs skip unless the PR is labeled + if: >- + contains(github.event.pull_request.labels.*.name, 'conformance') && + (github.event.action != 'labeled' || github.event.label.name == 'conformance') + runs-on: ubuntu-latest + steps: + # as release-docs downloads them + - name: Download merged + uses: actions/download-artifact@v8 + with: + pattern: conformance-results-* + merge-multiple: true + path: merged + # as create-github-release downloads them + - name: Download unmerged + uses: actions/download-artifact@v8 + with: + path: artifacts + - name: Check the reports + run: | + find merged artifacts -name report.html + test -f merged/2.7/L2/report.html + test -f merged/3.1r1/L3/report.html + test -f artifacts/conformance-results-2.7/2.7/L2/report.html + test -f artifacts/conformance-results-3.1r1/3.1r1/L3/report.html From 4b4f556a6d890ac19d275e66fd10442901273504 Mon Sep 17 00:00:00 2001 From: jadamcrain Date: Fri, 16 Oct 2026 09:08:16 -0700 Subject: [PATCH 4/6] Tag the commit that passed the conformance tests by SHA --- RELEASE.md | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/RELEASE.md b/RELEASE.md index 8592b1c3..285b6499 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -31,14 +31,20 @@ jobs are green on its latest revision, after its final update with `main`. ## 2. Tag on `main` and push CI only publishes from tags, and by convention tags live on `main`. Tag the -commit the `prepare` PR's merge produced. If you tag a later commit, run the -conformance tests on it first (`gh workflow run conformance.yml --ref main`). +commit the `prepare` PR's merge produced, by its SHA, since `main` may have +moved since: ```bash git checkout main && git pull -git tag X.Y.Z && git push origin X.Y.Z +SHA=$(gh pr view --json mergeCommit --jq .mergeCommit.oid) +git tag X.Y.Z "$SHA" && git push origin X.Y.Z ``` +To tag a later commit instead, run the conformance tests on it first +(`gh workflow run conformance.yml --ref main`), wait for the run to pass, and +tag the SHA it tested (its `headSha` in `gh run list --workflow conformance.yml +--json headSha,conclusion`). + crates.io publishing is **irreversible** — a version can never be re-published. Confirm the version before pushing. (`-RCn` publishes as a pre-release, which cargo won't select by default.) From cf3bb3c1b7bce0bc9a43e41b4d918aa6640ef71f Mon Sep 17 00:00:00 2001 From: jadamcrain Date: Fri, 16 Oct 2026 09:24:01 -0700 Subject: [PATCH 5/6] Add a ci-ok check that every PR job passed, and default CI's token to read ci-ok needs every job a PR runs, so branch protection can require one check instead of each matrix entry. Only create-github-release writes with GITHUB_TOKEN, so it asks for contents: write and the rest of CI reads. --- .github/workflows/ci.yml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c4b0ec21..9ec5ec0f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,6 +4,9 @@ on: tags: - "*" pull_request: +# Jobs that write with GITHUB_TOKEN ask for it themselves +permissions: + contents: read jobs: # Check formatting and run clippy lints linting: @@ -363,6 +366,16 @@ jobs: conformance: if: startsWith(github.ref, 'refs/tags/') uses: ./.github/workflows/conformance.yml + # The check branch protection requires: passes only if every job a PR runs passed + ci-ok: + needs: [linting, msrv, test, features, packaging] + if: always() + runs-on: ubuntu-latest + steps: + - name: Check the CI jobs + run: | + echo '${{ toJSON(needs) }}' + [ "${{ contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') || contains(needs.*.result, 'skipped') }}" = "false" ] # Quality gate to ensure all tests pass before releases quality-gate: needs: [linting, msrv, test, features, conformance] @@ -564,6 +577,9 @@ jobs: needs: [release-docs, release-maven, release-nuget, release-crates-io] runs-on: ubuntu-latest if: startsWith(github.ref, 'refs/tags/') + # creates the draft release and uploads its assets + permissions: + contents: write steps: - name: Download all release artifacts uses: actions/download-artifact@v8 From 059deb73bf32fd7f475f3b03a8423a8cb347ca34 Mon Sep 17 00:00:00 2001 From: jadamcrain Date: Fri, 16 Oct 2026 09:24:01 -0700 Subject: [PATCH 6/6] Remove the temporary conformance call test --- .github/workflows/conformance-call-test.yml | 35 --------------------- 1 file changed, 35 deletions(-) delete mode 100644 .github/workflows/conformance-call-test.yml diff --git a/.github/workflows/conformance-call-test.yml b/.github/workflows/conformance-call-test.yml deleted file mode 100644 index 3d8b83e5..00000000 --- a/.github/workflows/conformance-call-test.yml +++ /dev/null @@ -1,35 +0,0 @@ -# TEMPORARY: checks that ci.yml's call into conformance.yml hands the release jobs their artifacts. Remove before merging. -name: Conformance call test -on: - pull_request: - types: [opened, synchronize, reopened, labeled] -jobs: - conformance: - uses: ./.github/workflows/conformance.yml - check-artifacts: - needs: [conformance] - # the called jobs skip unless the PR is labeled - if: >- - contains(github.event.pull_request.labels.*.name, 'conformance') && - (github.event.action != 'labeled' || github.event.label.name == 'conformance') - runs-on: ubuntu-latest - steps: - # as release-docs downloads them - - name: Download merged - uses: actions/download-artifact@v8 - with: - pattern: conformance-results-* - merge-multiple: true - path: merged - # as create-github-release downloads them - - name: Download unmerged - uses: actions/download-artifact@v8 - with: - path: artifacts - - name: Check the reports - run: | - find merged artifacts -name report.html - test -f merged/2.7/L2/report.html - test -f merged/3.1r1/L3/report.html - test -f artifacts/conformance-results-2.7/2.7/L2/report.html - test -f artifacts/conformance-results-3.1r1/3.1r1/L3/report.html